-
# frozen_string_literal: true
-
-
1
require 'optparse'
-
1
require 'json'
-
1
require_relative '../../core/config_manager'
-
1
require_relative '../../core/project_finder'
-
1
require_relative '../../core/logger'
-
1
require_relative '../../core/attribute_validator'
-
1
require_relative '../../core/binding_validator'
-
-
1
module KjuiTools
-
1
module CLI
-
1
module Commands
-
1
class Build
-
1
def run(args)
-
9
options = parse_options(args)
-
-
# Detect mode
-
9
mode = options[:mode] || Core::ConfigManager.get('mode') || 'compose'
-
-
# Store validation results
-
9
@validation_warnings = []
-
9
@validation_errors = 0
-
-
9
case mode
-
when 'xml', 'all'
-
1
build_xml(options)
-
end
-
-
9
if mode == 'compose' || mode == 'all'
-
8
build_compose(options)
-
end
-
-
# Print validation summary if there were warnings
-
9
print_validation_summary if options[:validate] != false && @validation_warnings.any?
-
-
# Exit with error code if strict mode and there were validation errors
-
9
if options[:strict] && @validation_errors > 0
-
Core::Logger.error "Build failed: #{@validation_errors} validation error(s)"
-
exit 1
-
end
-
end
-
-
1
private
-
-
1
def parse_options(args)
-
9
options = {}
-
-
9
OptionParser.new do |opts|
-
9
opts.banner = "Usage: kjui build [options]"
-
-
9
opts.on('--mode MODE', ['all', 'xml', 'compose'],
-
'Build mode (all, xml, compose)') do |mode|
-
2
options[:mode] = mode
-
end
-
-
9
opts.on('--clean', 'Clean cache before building') do
-
1
options[:clean] = true
-
end
-
-
9
opts.on('--no-validate', 'Skip JSON attribute validation') do
-
2
options[:validate] = false
-
end
-
-
9
opts.on('--strict', 'Fail build on validation errors') do
-
1
options[:strict] = true
-
end
-
-
9
opts.on('-h', '--help', 'Show this help message') do
-
puts opts
-
exit
-
end
-
end.parse!(args)
-
-
# Validation is enabled by default
-
9
options[:validate] = true if options[:validate].nil?
-
-
9
options
-
end
-
-
1
def print_validation_summary
-
2
Core::Logger.info "-" * 60
-
2
Core::Logger.warn "Validation Summary: #{@validation_warnings.length} warning(s) found"
-
2
@validation_warnings.each do |warning|
-
9
puts " \e[33m#{warning}\e[0m"
-
end
-
end
-
-
# Validate a JSON component and all its children recursively
-
1
def validate_json(json_data, validator, file_name)
-
4
return [] unless json_data.is_a?(Hash)
-
-
4
warnings = validator.validate(json_data)
-
-
# Validate children recursively
-
4
children = json_data['child'] || json_data['children'] || []
-
4
children = [children] unless children.is_a?(Array)
-
-
4
children.each do |child|
-
2
warnings.concat(validate_json(child, validator, file_name)) if child.is_a?(Hash)
-
end
-
-
# Validate sections (for Collection/Table)
-
4
if json_data['sections'].is_a?(Array)
-
json_data['sections'].each do |section|
-
if section.is_a?(Hash)
-
['header', 'footer', 'cell'].each do |key|
-
warnings.concat(validate_json(section[key], validator, file_name)) if section[key].is_a?(Hash)
-
end
-
end
-
end
-
end
-
-
4
warnings
-
end
-
-
1
def build_xml(options = {})
-
Core::Logger.info "Building XML View files..."
-
-
# Setup project paths
-
Core::ProjectFinder.setup_paths
-
-
require_relative '../../xml/xml_builder'
-
builder = Xml::XmlBuilder.new
-
-
# Pass validation options to builder
-
builder.validation_enabled = options[:validate]
-
builder.validation_callback = ->(file, warnings) {
-
if warnings.any?
-
@validation_warnings.concat(warnings.map { |w| "[#{file}] #{w}" })
-
@validation_errors += warnings.length
-
end
-
} if options[:validate]
-
-
builder.build(options)
-
-
Core::Logger.success "XML build completed!"
-
end
-
-
1
def build_compose(options = {})
-
8
Core::Logger.info "Building Compose files..."
-
-
# Setup project paths
-
8
Core::ProjectFinder.setup_paths
-
-
8
require_relative '../../compose/compose_builder'
-
8
require_relative '../../compose/build_cache_manager'
-
-
8
config = Core::ConfigManager.load_config
-
8
source_path = Core::ProjectFinder.get_full_source_path || Dir.pwd
-
8
source_directory = config['source_directory'] || 'src/main'
-
8
layouts_dir = File.join(source_path, source_directory, config['layouts_directory'] || 'assets/Layouts')
-
-
# Initialize cache manager
-
8
cache_manager = Compose::BuildCacheManager.new(source_path)
-
-
# Clean cache if --clean option is specified
-
8
if options[:clean]
-
1
Core::Logger.info "Cleaning build cache..."
-
1
cache_manager.clean_cache
-
end
-
-
8
last_updated = cache_manager.load_last_updated
-
8
last_including_files = cache_manager.load_last_including_files
-
8
style_dependencies = cache_manager.load_style_dependencies
-
-
# Process all JSON files in Layouts directory (excluding Resources folder)
-
8
json_files = Dir.glob(File.join(layouts_dir, '**/*.json')).reject do |file|
-
3
file.include?('/Resources/')
-
end
-
-
8
if json_files.empty?
-
5
Core::Logger.warn "No JSON files found in #{layouts_dir}"
-
5
return
-
end
-
-
# Extract resources before processing layouts
-
3
require_relative '../../core/resources_manager'
-
3
resources_manager = Core::ResourcesManager.new(config, source_path)
-
3
resources_manager.extract_resources(json_files)
-
3
Core::Logger.info "-" * 60
-
-
# Track new includes and style dependencies
-
3
new_including_files = {}
-
3
new_style_dependencies = {}
-
-
# Filter files that need update
-
3
files_to_update = []
-
3
json_files.each do |json_file|
-
3
file_name = File.basename(json_file, '.json')
-
-
# Check if file needs update
-
3
if cache_manager.needs_update?(json_file, last_updated, layouts_dir, last_including_files, style_dependencies)
-
3
files_to_update << json_file
-
else
-
# Keep existing includes and style dependencies for unchanged files
-
new_including_files[file_name] = last_including_files[file_name] if last_including_files[file_name]
-
new_style_dependencies[file_name] = style_dependencies[file_name] if style_dependencies[file_name]
-
end
-
end
-
-
# Update data models first (always run to ensure data models are in sync)
-
3
require_relative '../../compose/data_model_updater'
-
3
data_updater = Compose::DataModelUpdater.new
-
3
data_updater.update_data_models(files_to_update)
-
-
3
if files_to_update.empty?
-
Core::Logger.info "No files need updating (all cached)"
-
return
-
end
-
-
3
Core::Logger.info "Updating #{files_to_update.length} of #{json_files.length} files..."
-
-
# Initialize validators if validation is enabled
-
3
validator = options[:validate] ? Core::AttributeValidator.new(:compose) : nil
-
3
binding_validator = options[:validate] ? Core::BindingValidator.new : nil
-
-
3
builder = Compose::ComposeBuilder.new
-
-
3
files_to_update.each do |json_file|
-
3
relative_path = Pathname.new(json_file).relative_path_from(Pathname.new(layouts_dir)).to_s
-
3
file_name = File.basename(json_file, '.json')
-
-
begin
-
# Read and parse JSON
-
3
json_content = File.read(json_file)
-
3
json_data = JSON.parse(json_content)
-
-
# Validate attributes if enabled
-
3
if validator
-
2
warnings = validate_json(json_data, validator, file_name)
-
2
if warnings.any?
-
11
@validation_warnings.concat(warnings.map { |w| "[#{relative_path}] #{w}" })
-
2
@validation_errors += warnings.length
-
2
Core::Logger.warn " #{warnings.length} attribute warning(s) in #{relative_path}"
-
end
-
end
-
-
# Validate bindings for business logic
-
3
if binding_validator
-
2
binding_warnings = binding_validator.validate(json_data, relative_path)
-
2
if binding_warnings.any?
-
@validation_warnings.concat(binding_warnings)
-
Core::Logger.warn " #{binding_warnings.length} binding warning(s) in #{relative_path}"
-
end
-
end
-
-
# Extract includes and styles for cache tracking
-
3
includes = cache_manager.extract_includes(json_data)
-
3
styles = cache_manager.extract_styles(json_data)
-
-
3
new_including_files[file_name] = includes if includes.any?
-
3
new_style_dependencies[file_name] = styles if styles.any?
-
-
# Build Compose file
-
3
Core::Logger.info "Processing: #{relative_path}"
-
3
builder.build_file(json_file)
-
-
rescue JSON::ParserError => e
-
Core::Logger.error "Failed to parse #{json_file}: #{e.message}"
-
rescue => e
-
Core::Logger.error "Failed to process #{json_file}: #{e.message}"
-
end
-
end
-
-
# Save cache for next build
-
3
cache_manager.save_cache(new_including_files, new_style_dependencies)
-
-
3
Core::Logger.success "Compose build completed!"
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'optparse'
-
1
require_relative '../../core/config_manager'
-
1
require_relative '../../core/project_finder'
-
-
1
module KjuiTools
-
1
module CLI
-
1
module Commands
-
1
class Generate
-
1
SUBCOMMANDS = {
-
'view' => 'Generate a new view with JSON and binding',
-
'partial' => 'Generate a partial view',
-
'collection' => 'Generate a collection view',
-
'cell' => 'Generate a collection cell view',
-
'binding' => 'Generate binding file',
-
'converter' => 'Generate a custom component converter'
-
}.freeze
-
-
1
def run(args)
-
# Parse global options first
-
25
global_options = parse_global_options(args)
-
-
25
subcommand = args.shift
-
-
# Load config to get default mode
-
25
config = Core::ConfigManager.load_config
-
-
# Use mode from options if provided, otherwise from config, otherwise default to compose
-
25
mode = global_options[:mode] || config['mode'] || 'compose'
-
-
# If no subcommand, generate all based on mode
-
25
if subcommand.nil?
-
6
if mode == 'xml'
-
1
generate_all_xml_layouts(config)
-
else
-
5
generate_all_compose_views(config)
-
end
-
6
return
-
end
-
-
19
if subcommand == 'help' || subcommand == '--help' || subcommand == '-h'
-
2
show_help
-
2
return
-
end
-
-
17
unless SUBCOMMANDS.key?(subcommand)
-
# Check if it's a layout name (no subcommand, just generate that layout)
-
3
if mode == 'xml' && !subcommand.start_with?('-')
-
1
generate_specific_xml_layout(subcommand, args, config)
-
1
return
-
2
elsif !subcommand.start_with?('-')
-
# For compose mode, treat it as a layout name and build it
-
1
puts "Building layout: #{subcommand}"
-
1
generate_specific_compose_layout(subcommand, args, config)
-
1
return
-
end
-
-
1
puts "Unknown generate command: #{subcommand}"
-
1
show_help
-
1
exit 1
-
end
-
-
14
case subcommand
-
when 'view'
-
4
generate_view(args, mode)
-
when 'partial'
-
1
generate_partial(args, mode)
-
when 'collection'
-
2
generate_collection(args, mode)
-
when 'cell'
-
3
generate_cell(args, mode)
-
when 'binding'
-
2
generate_binding(args, mode)
-
when 'converter'
-
2
generate_converter(args, mode)
-
end
-
end
-
-
1
private
-
-
1
def parse_global_options(args)
-
29
options = { mode: nil }
-
-
# Look for mode option and remove it from args
-
29
args.each_with_index do |arg, index|
-
30
if arg == '--mode' || arg == '-m'
-
9
if args[index + 1]
-
9
options[:mode] = args[index + 1]
-
9
args.delete_at(index + 1)
-
9
args.delete_at(index)
-
9
break
-
end
-
21
elsif arg.start_with?('--mode=')
-
2
options[:mode] = arg.split('=', 2)[1]
-
2
args.delete_at(index)
-
2
break
-
end
-
end
-
-
29
options
-
end
-
-
1
def generate_view(args, mode)
-
4
options = parse_view_options(args)
-
4
name = args.shift
-
-
4
if name.nil? || name.empty?
-
1
puts "Error: View name is required"
-
1
puts "Usage: kjui generate view <name> [options]"
-
1
exit 1
-
end
-
-
# Setup project paths
-
3
Core::ProjectFinder.setup_paths
-
-
3
case mode
-
when 'xml'
-
require_relative '../../xml/generators/view_generator'
-
generator = KjuiTools::Xml::Generators::ViewGenerator.new(name, options)
-
generator.generate
-
when 'compose'
-
2
require_relative '../../compose/generators/view_generator'
-
2
generator = KjuiTools::Compose::Generators::ViewGenerator.new(name, options)
-
2
generator.generate
-
else
-
1
puts "Error: Unknown mode: #{mode}"
-
1
exit 1
-
end
-
end
-
-
1
def generate_partial(args, mode)
-
1
name = args.shift
-
-
1
if name.nil? || name.empty?
-
1
puts "Error: Partial name is required"
-
1
puts "Usage: kjui generate partial <name>"
-
1
exit 1
-
end
-
-
case mode
-
when 'xml'
-
require_relative '../../xml/generators/partial_generator'
-
generator = KjuiTools::Xml::Generators::PartialGenerator.new(name)
-
generator.generate
-
when 'compose'
-
require_relative '../../compose/generators/partial_generator'
-
generator = KjuiTools::Compose::Generators::PartialGenerator.new(name)
-
generator.generate
-
end
-
end
-
-
1
def generate_collection(args, mode)
-
2
name = args.shift
-
-
2
if name.nil? || name.empty?
-
1
puts "Error: Collection name is required"
-
1
puts "Usage: kjui generate collection <name>"
-
1
exit 1
-
end
-
-
# Setup project paths
-
1
Core::ProjectFinder.setup_paths
-
-
1
case mode
-
when 'xml'
-
require_relative '../../xml/generators/collection_generator'
-
generator = KjuiTools::Xml::Generators::CollectionGenerator.new(name)
-
generator.generate
-
when 'compose'
-
require_relative '../../compose/generators/collection_generator'
-
generator = KjuiTools::Compose::Generators::CollectionGenerator.new(name)
-
generator.generate
-
else
-
1
puts "Error: Unknown mode: #{mode}"
-
1
exit 1
-
end
-
end
-
-
1
def generate_cell(args, mode)
-
3
name = args.shift
-
-
3
if name.nil? || name.empty?
-
1
puts "Error: Cell name is required"
-
1
puts "Usage: kjui generate cell <name>"
-
1
exit 1
-
end
-
-
# Setup project paths
-
2
Core::ProjectFinder.setup_paths
-
-
2
case mode
-
when 'xml'
-
1
puts "Cell generation is not available in XML mode"
-
1
exit 1
-
when 'compose'
-
require_relative '../../compose/generators/cell_generator'
-
generator = KjuiTools::Compose::Generators::CellGenerator.new(name)
-
generator.generate
-
else
-
1
puts "Error: Unknown mode: #{mode}"
-
1
exit 1
-
end
-
end
-
-
1
def generate_binding(args, mode)
-
2
name = args.shift
-
-
2
if name.nil? || name.empty?
-
1
puts "Error: Binding name is required"
-
1
puts "Usage: kjui generate binding <name>"
-
1
exit 1
-
end
-
-
1
if mode != 'xml'
-
1
puts "Binding generation is only available in XML mode"
-
1
exit 1
-
end
-
-
require_relative '../../xml/generators/binding_generator'
-
generator = KjuiTools::Xml::Generators::BindingGenerator.new(name)
-
generator.generate
-
end
-
-
1
def generate_converter(args, mode)
-
2
unless mode == 'compose'
-
1
puts "Converter generation is only available in Compose mode"
-
1
exit 1
-
end
-
-
1
name = args.shift
-
1
unless name
-
1
puts "Error: Please provide a component name"
-
1
puts "Usage: kjui generate converter <ComponentName> [options]"
-
1
puts "Options:"
-
1
puts " --container Generate as container component"
-
1
puts " --no-container Generate as non-container component"
-
1
puts " --attr KEY:TYPE Add attribute (can be used multiple times)"
-
1
puts " --binding KEY:TYPE Add binding attribute"
-
1
puts
-
1
puts "Examples:"
-
1
puts " kjui g converter MyCard --container"
-
1
puts " kjui g converter StatusBadge --attr text:String --attr color:Color"
-
1
puts " kjui g converter DataCard --binding title:String --attr icon:String"
-
1
exit 1
-
end
-
-
options = parse_converter_options(args)
-
-
require_relative '../../compose/generators/converter_generator'
-
generator = KjuiTools::Compose::Generators::ConverterGenerator.new(name, options)
-
generator.generate
-
end
-
-
1
def parse_converter_options(args)
-
options = {
-
7
is_container: nil,
-
attributes: {}
-
}
-
-
# Parse flags first
-
7
parser = OptionParser.new do |opts|
-
7
opts.on('--container', 'Generate as container component') do
-
1
options[:is_container] = true
-
end
-
-
7
opts.on('--no-container', 'Generate as non-container component') do
-
1
options[:is_container] = false
-
end
-
-
7
opts.on('--attr KEY:TYPE', 'Add attribute') do |attr|
-
3
key, type = attr.split(':')
-
3
if key && type
-
3
options[:attributes][key] = type
-
else
-
puts "Invalid attribute format. Use KEY:TYPE (e.g., text:String)"
-
exit 1
-
end
-
end
-
-
7
opts.on('--binding KEY:TYPE', 'Add binding attribute') do |attr|
-
1
key, type = attr.split(':')
-
1
if key && type
-
# Prefix with @ to indicate binding
-
1
options[:attributes]["@#{key}"] = type
-
else
-
puts "Invalid binding format. Use KEY:TYPE (e.g., title:String)"
-
exit 1
-
end
-
end
-
end
-
-
7
parser.parse!(args)
-
-
# Parse remaining arguments as attributes (simplified syntax)
-
7
args.each do |arg|
-
3
if arg.include?(':')
-
3
key, type = arg.split(':', 2)
-
3
if key && type
-
# Check if it's a binding (starts with @)
-
3
if key.start_with?('@')
-
1
options[:attributes][key] = type
-
else
-
2
options[:attributes][key] = type
-
end
-
end
-
end
-
end
-
-
7
options
-
end
-
-
1
def parse_view_options(args)
-
11
options = {
-
root: false,
-
mode: nil,
-
type: nil,
-
force: false
-
}
-
-
11
OptionParser.new do |opts|
-
11
opts.on('--root', 'Generate root view/activity') do
-
1
options[:root] = true
-
end
-
-
11
opts.on('--mode MODE', 'Override mode (xml, compose)') do |mode|
-
1
options[:mode] = mode
-
end
-
-
11
opts.on('--type TYPE', 'View type for XML mode (activity, fragment)') do |type|
-
1
options[:type] = type
-
end
-
-
11
opts.on('--activity', 'Generate as Activity (XML mode)') do
-
1
options[:type] = 'activity'
-
end
-
-
11
opts.on('--fragment', 'Generate as Fragment (XML mode)') do
-
1
options[:type] = 'fragment'
-
end
-
-
11
opts.on('-f', '--force', 'Force overwrite existing files') do
-
2
options[:force] = true
-
end
-
end.parse!(args)
-
-
11
options
-
end
-
-
1
def generate_all_xml_layouts(config)
-
require_relative '../../xml/xml_generator'
-
require_relative '../commands/generate_xml'
-
-
puts "Generating all XML layouts..."
-
CLI::Commands::GenerateXml.run([])
-
end
-
-
1
def generate_all_compose_views(config)
-
5
require_relative '../../compose/compose_builder'
-
-
5
puts "Generating all Compose views..."
-
# Call the existing Compose builder
-
5
system("ruby #{File.join(File.dirname(__FILE__), '../../..', 'bin', 'kjui')} build")
-
end
-
-
1
def generate_specific_xml_layout(layout_name, args, config)
-
require_relative '../../xml/xml_generator'
-
require_relative '../commands/generate_xml'
-
-
puts "Generating XML for layout: #{layout_name}"
-
CLI::Commands::GenerateXml.run([layout_name] + args)
-
end
-
-
1
def generate_specific_compose_layout(layout_name, args, config)
-
require_relative '../../compose/compose_builder'
-
-
puts "Building Compose layout: #{layout_name}"
-
# TODO: Implement single layout generation for compose
-
system("ruby #{File.join(File.dirname(__FILE__), '../../..', 'bin', 'kjui')} build")
-
end
-
-
1
def show_help
-
5
puts "Usage: kjui generate [SUBCOMMAND] [options]"
-
5
puts
-
5
puts "Global Options:"
-
5
puts " --mode, -m MODE Override mode (xml/compose)"
-
5
puts " Default: use config.json mode"
-
5
puts
-
5
puts "When in XML mode:"
-
5
puts " kjui generate # Generate all XML layouts"
-
5
puts " kjui generate test_menu # Generate specific XML layout"
-
5
puts
-
5
puts "When in Compose mode:"
-
5
puts " kjui generate # Generate all Compose views"
-
5
puts
-
5
puts "Subcommands:"
-
5
SUBCOMMANDS.each do |cmd, desc|
-
30
puts " #{cmd.ljust(12)} #{desc}"
-
end
-
5
puts
-
5
puts "View Options (XML mode):"
-
5
puts " --activity Generate as Activity (default)"
-
5
puts " --fragment Generate as Fragment"
-
5
puts " --type TYPE Specify type (activity/fragment)"
-
5
puts " -f, --force Force overwrite existing files"
-
5
puts
-
5
puts "Examples:"
-
5
puts " kjui g # Generate all (based on config mode)"
-
5
puts " kjui g --mode xml # Generate all XML layouts"
-
5
puts " kjui g --mode compose # Generate all Compose views"
-
5
puts " kjui g view HomeView --mode xml --activity # Generate Activity"
-
5
puts " kjui g view ProfileView --mode xml --fragment # Generate Fragment"
-
5
puts " kjui g view MainView --mode compose # Generate Compose view"
-
5
puts " kjui g converter MyCard --container # Generate custom component"
-
end
-
end
-
end
-
end
-
end
-
#!/usr/bin/env ruby
-
-
1
require_relative '../../core/config_manager'
-
1
require_relative '../../xml/xml_generator'
-
-
1
module CLI
-
1
module Commands
-
1
class GenerateXml
-
1
def self.run(args)
-
5
puts "🔧 KotlinJsonUI XML Generator"
-
5
puts "=============================="
-
-
# Load configuration
-
5
config = ConfigManager.load_config
-
-
5
if config.nil?
-
1
puts "❌ Error: config.json not found"
-
1
puts "Run 'kjui init --mode xml' first to create configuration"
-
1
return 1
-
end
-
-
# Check if XML mode is configured
-
4
if config['mode'] != 'xml'
-
1
puts "❌ Error: Project is configured for #{config['mode']} mode, not XML"
-
1
puts "Run 'kjui init --mode xml' to reconfigure for XML mode"
-
1
return 1
-
end
-
-
# Parse arguments
-
3
layout_name = nil
-
3
force = false
-
-
3
i = 0
-
3
while i < args.length
-
2
case args[i]
-
when '--layout', '-l'
-
layout_name = args[i + 1]
-
i += 1
-
when '--force', '-f'
-
force = true
-
when '--help', '-h'
-
2
show_help
-
2
return 0
-
else
-
if layout_name.nil? && !args[i].start_with?('-')
-
layout_name = args[i]
-
end
-
end
-
i += 1
-
end
-
-
1
if layout_name.nil?
-
# Generate all layouts
-
1
generate_all_layouts(config, force)
-
else
-
# Generate specific layout
-
generate_layout(layout_name, config, force)
-
end
-
-
1
0
-
rescue => e
-
puts "❌ Error: #{e.message}"
-
puts e.backtrace if ENV['DEBUG']
-
1
-
end
-
-
1
private
-
-
1
def self.generate_all_layouts(config, force)
-
1
layouts_dir = File.join(config['project_path'], 'app', 'src', 'main', 'assets', 'Layouts')
-
-
1
unless Dir.exist?(layouts_dir)
-
puts "❌ Error: Layouts directory not found: #{layouts_dir}"
-
return
-
end
-
-
1
json_files = Dir.glob(File.join(layouts_dir, '*.json'))
-
-
1
if json_files.empty?
-
1
puts "❌ No JSON layout files found in #{layouts_dir}"
-
1
return
-
end
-
-
puts "Found #{json_files.length} layout file(s)"
-
puts ""
-
-
success_count = 0
-
json_files.each do |json_file|
-
layout_name = File.basename(json_file, '.json')
-
-
if should_generate?(layout_name, config, force)
-
generator = XmlGenerator::Generator.new(layout_name, config)
-
if generator.generate
-
success_count += 1
-
end
-
else
-
puts "⏭️ Skipping #{layout_name} (up to date)"
-
end
-
end
-
-
puts ""
-
puts "✅ Successfully generated #{success_count} XML layout(s)"
-
end
-
-
1
def self.generate_layout(layout_name, config, force)
-
# Remove .json extension if present
-
layout_name = layout_name.sub(/\.json$/, '')
-
-
if should_generate?(layout_name, config, force)
-
generator = XmlGenerator::Generator.new(layout_name, config)
-
if generator.generate
-
puts "✅ Successfully generated XML for #{layout_name}"
-
else
-
puts "❌ Failed to generate XML for #{layout_name}"
-
end
-
else
-
puts "⏭️ Layout #{layout_name} is up to date (use --force to regenerate)"
-
end
-
end
-
-
1
def self.should_generate?(layout_name, config, force)
-
5
return true if force
-
-
# Check modification times
-
4
json_file = File.join(config['project_path'], 'app', 'src', 'main', 'assets', 'Layouts', "#{layout_name}.json")
-
4
xml_file = File.join(config['project_path'], 'app', 'src', 'main', 'res', 'layout', "#{layout_name.downcase}.xml")
-
-
4
return true unless File.exist?(xml_file)
-
2
return true unless File.exist?(json_file)
-
-
2
File.mtime(json_file) > File.mtime(xml_file)
-
end
-
-
1
def self.show_help
-
8
puts <<~HELP
-
Usage: kjui generate-xml [layout_name] [options]
-
-
Generate Android XML layouts from JSON files
-
-
Arguments:
-
layout_name Name of the layout to generate (optional)
-
If not specified, generates all layouts
-
-
Options:
-
-l, --layout <name> Specify layout name
-
-f, --force Force regeneration even if up to date
-
-h, --help Show this help message
-
-
Examples:
-
kjui generate-xml # Generate all layouts
-
kjui generate-xml test_menu # Generate specific layout
-
kjui generate-xml -f # Force regenerate all
-
kjui generate-xml test_menu -f # Force regenerate specific layout
-
HELP
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'fileutils'
-
1
require 'json'
-
1
require 'open3'
-
1
require_relative '../../hotloader/ip_monitor'
-
-
1
module KjuiTools
-
1
module CLI
-
1
module Commands
-
1
class Hotload
-
1
def self.run(args)
-
6
command = args.first
-
-
6
case command
-
when 'start', 'listen'
-
2
start_hotloader
-
when 'stop'
-
1
stop_hotloader
-
when 'status'
-
1
show_status
-
else
-
2
show_help
-
end
-
end
-
-
1
private
-
-
1
def self.start_hotloader
-
puts "Starting KotlinJsonUI HotLoader..."
-
puts "================================="
-
-
# Check if Node.js is installed
-
unless system('which node > /dev/null 2>&1')
-
puts "Error: Node.js is not installed. Please install Node.js first."
-
puts "Visit: https://nodejs.org/"
-
exit 1
-
end
-
-
# Find project root
-
project_root = find_project_root
-
hotloader_dir = File.join(File.dirname(__FILE__), '../../hotloader')
-
-
# Load config to get port
-
config_path = File.join(project_root, 'kjui.config.json')
-
config = File.exist?(config_path) ? JSON.parse(File.read(config_path)) : {}
-
port = config.dig('hotloader', 'port') || 8081
-
-
# Install npm dependencies if needed
-
Dir.chdir(hotloader_dir) do
-
unless Dir.exist?('node_modules')
-
puts "Installing dependencies..."
-
system('npm install')
-
end
-
end
-
-
# Kill any existing processes on the port
-
kill_port_process(port)
-
-
# Start IP monitor
-
ip_monitor = KjuiTools::Hotloader::IpMonitor.new(project_root)
-
ip_monitor.start
-
-
# Get current IP
-
ip = get_local_ip
-
puts "\nLocal IP: #{ip}"
-
puts "Port: #{port}"
-
-
# Start Node.js server
-
puts "\nStarting server..."
-
Dir.chdir(hotloader_dir) do
-
ENV['HOST'] = '0.0.0.0'
-
ENV['PORT'] = port.to_s
-
ENV['PROJECT_ROOT'] = project_root
-
-
# Start server in foreground
-
system('node server.js')
-
end
-
-
# Stop IP monitor when server stops
-
ip_monitor.stop
-
end
-
-
1
def self.stop_hotloader
-
puts "Stopping KotlinJsonUI HotLoader..."
-
-
# Load config to get port
-
project_root = find_project_root
-
config_path = File.join(project_root, 'kjui.config.json')
-
config = File.exist?(config_path) ? JSON.parse(File.read(config_path)) : {}
-
port = config.dig('hotloader', 'port') || 8081
-
-
# Kill Node.js server
-
kill_port_process(port)
-
-
# Kill any node processes running server.js
-
system("pkill -f 'node.*server.js'")
-
-
puts "HotLoader stopped"
-
end
-
-
1
def self.show_status
-
puts "KotlinJsonUI HotLoader Status"
-
puts "============================="
-
-
# Load config to get port
-
project_root = find_project_root
-
config_path = File.join(project_root, 'kjui.config.json')
-
config = File.exist?(config_path) ? JSON.parse(File.read(config_path)) : {}
-
port = config.dig('hotloader', 'port') || 8081
-
-
# Check if server is running
-
if port_in_use?(port)
-
puts "Status: ✅ Running"
-
-
# Try to get server info
-
begin
-
require 'net/http'
-
require 'uri'
-
-
ip = get_local_ip
-
uri = URI.parse("http://#{ip}:#{port}/")
-
response = Net::HTTP.get_response(uri)
-
-
if response.code == '200'
-
info = JSON.parse(response.body)
-
puts "Project: #{info['projectRoot']}"
-
puts "Connected clients: #{info['connectedClients']}"
-
end
-
rescue => e
-
puts "Server is running but couldn't get details"
-
end
-
else
-
puts "Status: ❌ Not running"
-
end
-
-
# Show configuration
-
if config['hotloader']
-
puts "\nConfiguration:"
-
puts "IP: #{config['hotloader']['ip']}"
-
puts "Port: #{config['hotloader']['port']}"
-
puts "Enabled: #{config['hotloader']['enabled']}"
-
end
-
end
-
-
1
def self.show_help
-
5
puts <<~HELP
-
KotlinJsonUI HotLoader Commands
-
===============================
-
-
Usage: kjui hotload <command>
-
-
Commands:
-
start, listen - Start the hotloader server
-
stop - Stop the hotloader server
-
status - Show server status
-
-
The hotloader enables real-time UI updates during development.
-
It watches for changes in Layouts/ and Styles/ directories and
-
automatically rebuilds and reloads the UI in your Android app.
-
-
Example:
-
kjui hotload start # Start development server
-
kjui hotload stop # Stop server
-
kjui hotload status # Check if server is running
-
HELP
-
end
-
-
1
def self.find_project_root(start_path = Dir.pwd)
-
4
current = start_path
-
-
4
while current != '/'
-
# Check for kjui.config.json
-
5
if File.exist?(File.join(current, 'kjui.config.json'))
-
2
return current
-
end
-
-
# Check for Android project files
-
3
if File.exist?(File.join(current, 'build.gradle.kts')) ||
-
File.exist?(File.join(current, 'settings.gradle.kts'))
-
2
return current
-
end
-
-
1
current = File.dirname(current)
-
end
-
-
Dir.pwd
-
end
-
-
1
def self.get_local_ip
-
1
require 'socket'
-
-
# Try common interface names
-
1
interfaces = ['wlan0', 'wlp2s0', 'en0', 'en1', 'eth0']
-
-
1
interfaces.each do |interface|
-
3
Socket.getifaddrs.each do |ifaddr|
-
107
if ifaddr.name == interface && ifaddr.addr&.ipv4?
-
1
ip = ifaddr.addr.ip_address
-
1
return ip unless ip.start_with?('127.')
-
end
-
end
-
end
-
-
# Fallback
-
Socket.ip_address_list.find { |ai| ai.ipv4? && !ai.ipv4_loopback? }&.ip_address || '127.0.0.1'
-
rescue
-
'127.0.0.1'
-
end
-
-
1
def self.port_in_use?(port)
-
1
system("lsof -i:#{port} > /dev/null 2>&1")
-
end
-
-
1
def self.kill_port_process(port)
-
if port_in_use?(port)
-
puts "Killing existing process on port #{port}..."
-
system("lsof -ti:#{port} | xargs kill -9 2>/dev/null")
-
sleep 1
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'optparse'
-
1
require 'fileutils'
-
1
require 'json'
-
1
require_relative '../../core/config_manager'
-
1
require_relative '../../core/project_finder'
-
-
1
module KjuiTools
-
1
module CLI
-
1
module Commands
-
1
class Init
-
1
def run(args)
-
10
options = parse_options(args)
-
-
# Check if MODE file exists (set by installer)
-
9
installer_mode = nil
-
9
mode_file = File.join(File.dirname(__FILE__), '../../../../MODE')
-
9
if File.exist?(mode_file)
-
installer_mode = File.read(mode_file).strip
-
end
-
-
# Detect or use specified mode
-
9
mode = options[:mode] || installer_mode || Core::ConfigManager.detect_mode
-
-
9
puts "Initializing KotlinJsonUI project in #{mode} mode..."
-
-
# Create config file
-
9
create_config_file(mode)
-
-
# Create directory structure based on mode
-
9
case mode
-
when 'xml'
-
3
create_xml_structure
-
when 'compose'
-
6
create_compose_structure
-
when 'all'
-
create_xml_structure
-
create_compose_structure
-
end
-
-
9
puts "Initialization complete!"
-
9
puts
-
9
if mode == 'compose'
-
6
puts "Compose mode initialized. Use Compose-specific commands for your project."
-
else
-
3
puts "Next steps:"
-
3
puts " 1. Run 'kjui setup' to install dependencies and base files"
-
3
puts " 2. Run 'kjui g view HomeView' to generate your first view"
-
end
-
end
-
-
1
private
-
-
1
def parse_options(args)
-
10
options = {}
-
-
10
OptionParser.new do |opts|
-
10
opts.banner = "Usage: kjui init [options]"
-
-
10
opts.on('--mode MODE', ['all', 'xml', 'compose'],
-
'Initialize mode (all, xml, compose)') do |mode|
-
8
options[:mode] = mode
-
end
-
-
10
opts.on('-h', '--help', 'Show this help message') do
-
1
puts opts
-
1
exit
-
end
-
end.parse!(args)
-
-
9
options
-
end
-
-
1
def create_config_file(mode)
-
9
config_file = 'kjui.config.json'
-
-
9
if File.exist?(config_file)
-
1
puts "Config file already exists: #{config_file}"
-
# Check if source_directory needs to be updated
-
1
existing_config = JSON.parse(File.read(config_file))
-
1
if existing_config['source_directory'].to_s.empty?
-
Core::ProjectFinder.setup_paths
-
# Auto-detect source directory without checking config
-
project_dir = Core::ProjectFinder.project_dir
-
-
# If project_dir is nil, fallback to finding gradle files
-
if project_dir.nil?
-
gradle_file = Dir.glob('build.gradle*').first || Dir.glob('../build.gradle*').first
-
project_dir = gradle_file ? File.dirname(File.expand_path(gradle_file)) : Dir.pwd
-
end
-
-
common_names = ['app/src/main', 'src/main', 'src', File.basename(project_dir)]
-
-
source_dir = nil
-
common_names.each do |name|
-
path = File.join(project_dir, name)
-
if Dir.exist?(path)
-
source_dir = name
-
break
-
end
-
end
-
-
if source_dir && !source_dir.empty?
-
existing_config['source_directory'] = source_dir
-
File.write(config_file, JSON.pretty_generate(existing_config))
-
puts "Updated source_directory to: #{source_dir}"
-
end
-
end
-
1
return
-
end
-
-
# Find project info
-
8
Core::ProjectFinder.setup_paths
-
-
# Get project name from settings.gradle or current directory
-
8
project_name = get_project_name_from_gradle || File.basename(Dir.pwd)
-
-
# Create base config based on mode
-
8
if mode == 'compose'
-
# Detect package name
-
5
package_name = Core::ProjectFinder.package_name
-
-
# Compose-specific config with appropriate defaults
-
# Detect if we're in a module or main app
-
5
source_dir = if Dir.exist?('src/main')
-
'src/main'
-
5
elsif Dir.exist?('app/src/main')
-
'app/src/main'
-
else
-
5
Core::ProjectFinder.find_source_directory || 'src/main'
-
end
-
-
config = {
-
5
'mode' => mode,
-
'project_name' => project_name,
-
'source_directory' => source_dir,
-
'layouts_directory' => 'assets/Layouts',
-
'styles_directory' => 'assets/Styles',
-
'data_directory' => "kotlin/#{package_name.gsub('.', '/')}/data",
-
'viewmodel_directory' => "kotlin/#{package_name.gsub('.', '/')}/viewmodels",
-
'view_directory' => "kotlin/#{package_name.gsub('.', '/')}/views",
-
'extension_directory' => "kotlin/#{package_name.gsub('.', '/')}/extensions",
-
'adapter_directory' => "kotlin/#{package_name.gsub('.', '/')}/adapters",
-
'resource_manager_directory' => "kotlin/#{package_name.gsub('.', '/')}/generated",
-
'package_name' => package_name,
-
'string_files' => [
-
'res/values/strings.xml',
-
'res/values-ja/strings.xml'
-
],
-
'use_network' => true, # Compose mode can use network for API calls
-
'hotloader' => {
-
'ip' => '127.0.0.1',
-
'port' => 8081,
-
'watch_directories' => ['assets/Layouts', 'assets/Styles']
-
}
-
}
-
else
-
# XML mode or all mode config
-
config = {
-
3
'mode' => mode,
-
'project_name' => project_name,
-
'project_file_name' => project_name,
-
'source_directory' => Core::ProjectFinder.find_source_directory || 'app/src/main',
-
'layouts_directory' => 'res/raw/layouts',
-
'styles_directory' => 'res/raw/styles',
-
'view_directory' => 'java/com/example/app/ui',
-
'data_directory' => 'java/com/example/app/data',
-
'viewmodel_directory' => 'java/com/example/app/viewmodel',
-
'bindings_directory' => 'java/com/example/app/bindings',
-
'extension_directory' => 'java/com/example/app/extensions',
-
'adapter_directory' => 'java/com/example/app/adapters',
-
'resource_manager_directory' => 'java/com/example/app/generated',
-
'string_files' => [
-
'res/values/strings.xml',
-
'res/values-ja/strings.xml'
-
],
-
'use_network' => true,
-
'hotloader' => {
-
'ip' => '127.0.0.1',
-
'port' => 8081,
-
'watch_directories' => ['res/raw/layouts', 'res/raw/styles']
-
}
-
}
-
-
# Add Compose config if mode is 'all'
-
3
if mode == 'all'
-
config['compose'] = {
-
'output_directory' => 'java/com/example/app/generated'
-
}
-
end
-
end
-
-
8
File.write(config_file, JSON.pretty_generate(config))
-
8
puts "Created config file: #{config_file}"
-
end
-
-
1
def create_xml_structure
-
3
directories = %w[
-
res/raw/layouts
-
res/raw/styles
-
java/com/example/app/ui
-
java/com/example/app/ui/activities
-
java/com/example/app/ui/fragments
-
java/com/example/app/data
-
java/com/example/app/viewmodel
-
java/com/example/app/bindings
-
java/com/example/app/core
-
java/com/example/app/core/base
-
]
-
-
3
create_directories(directories)
-
end
-
-
1
def create_compose_structure
-
# Read config to get directory names
-
6
config = Core::ConfigManager.load_config
-
6
source_dir = config['source_directory'] || 'app/src/main'
-
-
directories = [
-
6
File.join(source_dir, config['layouts_directory'] || 'assets/Layouts'),
-
File.join(source_dir, config['styles_directory'] || 'assets/Styles')
-
]
-
-
# Add data directory if configured
-
6
if config['data_directory']
-
directories << File.join(source_dir, config['data_directory'])
-
end
-
-
# Add viewmodel directory if configured
-
6
if config['viewmodel_directory']
-
directories << File.join(source_dir, config['viewmodel_directory'])
-
end
-
-
# Add view directory if configured
-
6
if config['view_directory']
-
directories << File.join(source_dir, config['view_directory'])
-
end
-
-
6
create_directories(directories)
-
end
-
-
1
def create_directories(directories)
-
9
directories.each do |dir|
-
42
unless Dir.exist?(dir)
-
42
FileUtils.mkdir_p(dir)
-
42
puts "Created directory: #{dir}"
-
end
-
end
-
end
-
-
1
def get_project_name_from_gradle
-
# Try settings.gradle.kts first
-
8
if File.exist?('settings.gradle.kts')
-
content = File.read('settings.gradle.kts')
-
if content =~ /rootProject\.name\s*=\s*["']([^"']+)["']/
-
return $1
-
end
-
end
-
-
# Try settings.gradle
-
8
if File.exist?('settings.gradle')
-
content = File.read('settings.gradle')
-
if content =~ /rootProject\.name\s*=\s*["']([^"']+)["']/
-
return $1
-
end
-
end
-
-
nil
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'optparse'
-
1
require 'fileutils'
-
1
require_relative '../../core/config_manager'
-
1
require_relative '../../core/project_finder'
-
-
1
module KjuiTools
-
1
module CLI
-
1
module Commands
-
1
class Setup
-
1
def run(args)
-
6
options = parse_options(args)
-
-
# Check and install dependencies first
-
6
ensure_dependencies_installed
-
-
# Setup project paths
-
6
Core::ProjectFinder.setup_paths
-
-
# Load config to determine mode
-
6
config = Core::ConfigManager.load_config
-
6
mode = config['mode'] || 'compose'
-
-
6
puts "Setting up KotlinJsonUI project in #{mode} mode..."
-
-
# Setup based on mode
-
6
case mode
-
when 'compose'
-
3
setup_compose_project
-
when 'xml'
-
2
setup_xml_project
-
when 'all'
-
1
setup_xml_project
-
1
setup_compose_project
-
end
-
-
6
puts "\nSetup complete!"
-
6
if mode == 'compose'
-
3
puts "Next steps:"
-
3
puts " 1. Create your layouts in the assets/Layouts directory"
-
3
puts " 2. Run 'kjui convert' to generate Compose code"
-
3
puts " 3. Build your project with Gradle"
-
else
-
3
puts "Next steps:"
-
3
puts " 1. Run 'kjui g view HomeView' to generate your first view"
-
3
puts " 2. Build your project with Gradle"
-
end
-
end
-
-
1
private
-
-
1
def ensure_dependencies_installed
-
# Check if Gemfile.lock exists
-
kjui_tools_dir = File.expand_path('../../../..', __FILE__)
-
gemfile_lock = File.join(kjui_tools_dir, 'Gemfile.lock')
-
-
unless File.exist?(gemfile_lock)
-
puts "Installing kjui_tools dependencies..."
-
Dir.chdir(kjui_tools_dir) do
-
success = system('bundle install')
-
unless success
-
puts "Warning: Failed to install some dependencies"
-
puts "You may need to install them manually with: cd kjui_tools && bundle install"
-
end
-
end
-
end
-
end
-
-
1
def parse_options(args)
-
9
options = {}
-
-
9
OptionParser.new do |opts|
-
9
opts.banner = "Usage: kjui setup [options]"
-
-
9
opts.on('-h', '--help', 'Show this help message') do
-
2
puts opts
-
2
exit
-
end
-
end.parse!(args)
-
-
7
options
-
end
-
-
1
def setup_compose_project
-
require_relative '../../compose/setup/compose_setup'
-
-
# Use the Compose-specific setup
-
setup = ::KjuiTools::Compose::Setup::ComposeSetup.new(Core::ProjectFinder.project_file_path)
-
setup.run_full_setup
-
end
-
-
1
def setup_xml_project
-
require_relative '../../xml/setup/xml_setup'
-
-
# Use the XML-specific setup
-
setup = ::KjuiTools::Xml::Setup::XmlSetup.new(Core::ProjectFinder.project_file_path)
-
setup.run_full_setup
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative 'version'
-
1
require_relative 'commands/init'
-
1
require_relative 'commands/setup'
-
1
require_relative 'commands/build'
-
1
require_relative 'commands/generate'
-
1
require_relative 'commands/hotload'
-
-
1
module KjuiTools
-
1
module CLI
-
1
class Main
-
1
def self.run(args)
-
19
command = args.shift
-
-
19
case command
-
when 'init'
-
1
Commands::Init.new.run(args)
-
when 'setup'
-
1
Commands::Setup.new.run(args)
-
when 'generate', 'g'
-
2
Commands::Generate.new.run(args)
-
when 'build', 'b'
-
2
Commands::Build.new.run(args)
-
when 'hotload', 'hot'
-
2
Commands::Hotload.run(args)
-
when 'watch', 'w'
-
2
puts "Watch command not yet implemented"
-
when 'version', 'v', '--version', '-v'
-
4
puts "KotlinJsonUI Tools version #{VERSION}"
-
when 'help', '--help', '-h', nil
-
4
show_help
-
else
-
1
puts "Unknown command: #{command}"
-
1
show_help
-
1
exit 1
-
end
-
rescue StandardError => e
-
puts "Error: #{e.message}"
-
puts e.backtrace if ENV['DEBUG']
-
exit 1
-
end
-
-
1
def self.show_help
-
11
puts <<~HELP
-
KotlinJsonUI Tools - JSON-based UI framework for Android
-
-
Usage: kjui <command> [options]
-
-
Commands:
-
init Initialize a new KotlinJsonUI project
-
generate, g Generate views and components
-
setup Set up project dependencies
-
build, b Build the project
-
hotload, hot Start/stop hotload server for real-time updates
-
watch, w Watch for file changes
-
version, v Show version information
-
help Show this help message
-
-
Examples:
-
kjui init --mode compose Initialize a Jetpack Compose project
-
kjui init --mode xml Initialize an XML-based project
-
kjui g view HomeView Generate a new view
-
-
For more information on a specific command:
-
kjui <command> --help
-
HELP
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module KjuiTools
-
1
module CLI
-
1
VERSION = '1.0.0'
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'json'
-
1
require 'fileutils'
-
1
require 'pathname'
-
1
require 'digest'
-
-
1
module KjuiTools
-
1
module Compose
-
1
class BuildCacheManager
-
1
def initialize(source_path)
-
26
@source_path = source_path
-
26
@cache_dir = File.join(source_path, '.kjui_cache')
-
26
@last_updated_file = File.join(@cache_dir, 'last_updated.json')
-
26
@including_files_cache = File.join(@cache_dir, 'including_files.json')
-
26
@style_dependencies_cache = File.join(@cache_dir, 'style_dependencies.json')
-
-
# Create cache directory if it doesn't exist
-
26
FileUtils.mkdir_p(@cache_dir) unless File.exist?(@cache_dir)
-
end
-
-
1
def load_last_updated
-
11
return {} unless File.exist?(@last_updated_file)
-
2
JSON.parse(File.read(@last_updated_file))
-
rescue JSON::ParserError
-
1
{}
-
end
-
-
1
def load_last_including_files
-
10
return {} unless File.exist?(@including_files_cache)
-
1
JSON.parse(File.read(@including_files_cache))
-
rescue JSON::ParserError
-
{}
-
end
-
-
1
def load_style_dependencies
-
9
return {} unless File.exist?(@style_dependencies_cache)
-
JSON.parse(File.read(@style_dependencies_cache))
-
rescue JSON::ParserError
-
{}
-
end
-
-
1
def needs_update?(json_file, last_updated, layouts_dir, last_including_files, style_dependencies)
-
6
file_name = File.basename(json_file, '.json')
-
-
# Check if file exists in last_updated
-
6
return true unless last_updated[file_name]
-
-
# Check if file has been modified
-
2
file_mtime = File.mtime(json_file).to_i
-
2
return true if file_mtime > last_updated[file_name]['mtime'].to_i
-
-
# Check if any included files have been modified
-
1
if last_including_files[file_name]
-
last_including_files[file_name].each do |included_file|
-
included_path = File.join(layouts_dir, "#{included_file}.json")
-
if File.exist?(included_path)
-
included_mtime = File.mtime(included_path).to_i
-
return true if included_mtime > last_updated[file_name]['mtime'].to_i
-
end
-
end
-
end
-
-
# Check if any style dependencies have been modified
-
1
if style_dependencies[file_name]
-
styles_dir = File.join(@source_path, 'assets', 'Styles')
-
style_dependencies[file_name].each do |style_file|
-
style_path = File.join(styles_dir, "#{style_file}.json")
-
if File.exist?(style_path)
-
style_mtime = File.mtime(style_path).to_i
-
return true if style_mtime > last_updated[file_name]['mtime'].to_i
-
end
-
end
-
end
-
-
# Check if any file that includes this file has been modified
-
1
last_including_files.each do |parent_file, includes|
-
if includes && includes.include?(file_name)
-
parent_path = File.join(layouts_dir, "#{parent_file}.json")
-
if File.exist?(parent_path)
-
parent_mtime = File.mtime(parent_path).to_i
-
return true if parent_mtime > last_updated[file_name]['mtime'].to_i
-
end
-
end
-
end
-
-
1
false
-
end
-
-
1
def extract_includes(json_data, includes = Set.new)
-
16
if json_data.is_a?(Hash)
-
# Check for include
-
15
if json_data['include']
-
6
includes.add(json_data['include'])
-
end
-
-
# Process children
-
15
if json_data['child']
-
6
if json_data['child'].is_a?(Array)
-
4
json_data['child'].each do |child|
-
5
extract_includes(child, includes)
-
end
-
else
-
2
extract_includes(json_data['child'], includes)
-
end
-
end
-
1
elsif json_data.is_a?(Array)
-
1
json_data.each do |item|
-
2
extract_includes(item, includes)
-
end
-
end
-
-
16
includes.to_a
-
end
-
-
1
def extract_styles(json_data, styles = Set.new)
-
10
if json_data.is_a?(Hash)
-
# Check for style attribute
-
10
if json_data['style']
-
3
styles.add(json_data['style'])
-
end
-
-
# Process children
-
10
if json_data['child']
-
4
if json_data['child'].is_a?(Array)
-
4
json_data['child'].each do |child|
-
5
extract_styles(child, styles)
-
end
-
else
-
extract_styles(json_data['child'], styles)
-
end
-
end
-
elsif json_data.is_a?(Array)
-
json_data.each do |item|
-
extract_styles(item, styles)
-
end
-
end
-
-
10
styles.to_a
-
end
-
-
1
def save_cache(including_files, style_dependencies)
-
# Update last_updated with current timestamps
-
4
last_updated = {}
-
-
# Get all processed files
-
4
all_files = (including_files.keys + style_dependencies.keys).uniq
-
-
4
all_files.each do |file_name|
-
1
layouts_dir = File.join(@source_path, 'assets', 'Layouts')
-
1
json_file = File.join(layouts_dir, "#{file_name}.json")
-
-
1
if File.exist?(json_file)
-
1
last_updated[file_name] = {
-
'mtime' => File.mtime(json_file).to_i,
-
'hash' => Digest::MD5.hexdigest(File.read(json_file))
-
}
-
end
-
end
-
-
# Save all cache files
-
4
File.write(@last_updated_file, JSON.pretty_generate(last_updated))
-
4
File.write(@including_files_cache, JSON.pretty_generate(including_files))
-
4
File.write(@style_dependencies_cache, JSON.pretty_generate(style_dependencies))
-
end
-
-
1
def clean_cache
-
2
FileUtils.rm_rf(@cache_dir)
-
2
FileUtils.mkdir_p(@cache_dir)
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class BlurviewComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
# BlurView in Compose requires a special modifier or library
-
# For now, we'll create a semi-transparent overlay as a fallback
-
3
code = indent("Box(", depth)
-
-
# Build modifiers
-
3
modifiers = []
-
3
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
3
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
3
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
# Add blur effect
-
3
blur_radius = json_data['blurRadius'] || 10
-
-
# Try to use real blur modifier (available in Compose 1.3+)
-
3
required_imports&.add(:blur)
-
3
modifiers << ".blur(#{blur_radius}.dp)"
-
-
# Background color
-
3
if json_data['backgroundColor']
-
bg_color = json_data['backgroundColor']
-
opacity = json_data['opacity'] || 0.8
-
modifiers << ".background(Helpers::ResourceResolver.process_color('#{bg_color}', required_imports).copy(alpha = #{opacity}f))"
-
end
-
-
# Add corner radius if specified
-
3
if json_data['cornerRadius']
-
required_imports&.add(:shape)
-
modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
-
end
-
-
3
modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
-
-
3
code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
-
3
code += "\n" + indent(") {", depth)
-
-
# Process children
-
3
children = json_data['child'] || []
-
3
children = [children] unless children.is_a?(Array)
-
-
# Return structure for parent to process children
-
3
{ code: code, children: children, closing: "\n" + indent("}", depth), json_data: json_data }
-
end
-
-
1
private
-
-
1
def self.indent(text, level)
-
9
return text if level == 0
-
spaces = ' ' * level
-
text.split("\n").map { |line|
-
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class ButtonComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
# Button uses 'text' attribute per SwiftJsonUI spec
-
26
text = Helpers::ResourceResolver.process_text(json_data['text'] || 'Button', required_imports)
-
-
26
code = indent("Button(", depth)
-
-
# Handle click events
-
# onclick (lowercase) -> selector format (string only)
-
# onClick (camelCase) -> binding format only (@{functionName})
-
26
if json_data['onclick']
-
1
handler_call = Helpers::ModifierBuilder.get_event_handler_call(json_data['onclick'], is_camel_case: false)
-
1
code += "\n" + indent("onClick = { #{handler_call} }", depth + 1)
-
25
elsif json_data['onClick']
-
handler_call = Helpers::ModifierBuilder.get_event_handler_call(json_data['onClick'], is_camel_case: true)
-
code += "\n" + indent("onClick = { #{handler_call} }", depth + 1)
-
else
-
25
code += "\n" + indent("onClick = { }", depth + 1)
-
end
-
-
# Build modifiers (only margins and size, not padding)
-
26
modifiers = []
-
26
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
26
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
-
# Format modifiers only if there are modifiers
-
26
if modifiers.any?
-
3
code += ","
-
3
code += Helpers::ModifierBuilder.format(modifiers, depth)
-
end
-
-
# Add shape with cornerRadius if specified
-
26
if json_data['cornerRadius']
-
1
required_imports&.add(:shape)
-
1
code += ",\n" + indent("shape = RoundedCornerShape(#{json_data['cornerRadius']}.dp)", depth + 1)
-
end
-
-
# Add contentPadding for internal padding
-
# Support both 'padding' (number), 'paddings' (array), and individual padding attributes
-
26
padding_data = json_data['paddings'] || json_data['padding']
-
-
26
if padding_data || json_data['paddingTop'] || json_data['paddingBottom'] ||
-
json_data['paddingLeft'] || json_data['paddingRight'] || json_data['paddingStart'] ||
-
json_data['paddingEnd'] || json_data['paddingHorizontal'] || json_data['paddingVertical']
-
7
required_imports&.add(:button_padding)
-
-
7
padding_values = []
-
-
7
if padding_data
-
# Handle paddings array or padding number
-
5
if padding_data.is_a?(Array)
-
4
case padding_data.length
-
when 1
-
# One value: all sides
-
1
padding_values << "#{padding_data[0]}.dp"
-
when 2
-
# Two values: [vertical, horizontal]
-
1
padding_values << "vertical = #{padding_data[0]}.dp"
-
1
padding_values << "horizontal = #{padding_data[1]}.dp"
-
when 3
-
# Three values: [top, horizontal, bottom]
-
1
padding_values << "top = #{padding_data[0]}.dp"
-
1
padding_values << "horizontal = #{padding_data[1]}.dp"
-
1
padding_values << "bottom = #{padding_data[2]}.dp"
-
when 4
-
# Four values: [top, right, bottom, left]
-
1
padding_values << "top = #{padding_data[0]}.dp"
-
1
padding_values << "end = #{padding_data[1]}.dp"
-
1
padding_values << "bottom = #{padding_data[2]}.dp"
-
1
padding_values << "start = #{padding_data[3]}.dp"
-
end
-
else
-
# Single number: all sides
-
1
padding_values << "#{padding_data}.dp"
-
end
-
else
-
# Handle individual padding attributes
-
2
top_padding = json_data['paddingTop'] || json_data['paddingVertical'] || 0
-
2
bottom_padding = json_data['paddingBottom'] || json_data['paddingVertical'] || 0
-
2
start_padding = json_data['paddingStart'] || json_data['paddingLeft'] || json_data['paddingHorizontal'] || 0
-
2
end_padding = json_data['paddingEnd'] || json_data['paddingRight'] || json_data['paddingHorizontal'] || 0
-
-
2
if top_padding == bottom_padding && start_padding == end_padding && top_padding == start_padding
-
# All same, use single value
-
padding_values << "#{top_padding}.dp" if top_padding > 0
-
2
elsif top_padding == bottom_padding && start_padding == end_padding
-
# Different horizontal and vertical
-
1
padding_values << "horizontal = #{start_padding}.dp" if start_padding > 0
-
1
padding_values << "vertical = #{top_padding}.dp" if top_padding > 0
-
else
-
# All different, need to specify each
-
1
padding_values << "start = #{start_padding}.dp" if start_padding > 0
-
1
padding_values << "top = #{top_padding}.dp" if top_padding > 0
-
1
padding_values << "end = #{end_padding}.dp" if end_padding > 0
-
1
padding_values << "bottom = #{bottom_padding}.dp" if bottom_padding > 0
-
end
-
end
-
-
7
if padding_values.any?
-
7
code += ",\n" + indent("contentPadding = PaddingValues(#{padding_values.join(', ')})", depth + 1)
-
end
-
end
-
-
# Button colors including normal, disabled, and pressed states
-
26
if json_data['background'] || json_data['disabledBackground'] || json_data['disabledFontColor'] || json_data['hilightColor']
-
4
required_imports&.add(:button_colors)
-
4
colors_code = "colors = ButtonDefaults.buttonColors("
-
4
color_params = []
-
-
4
if json_data['background']
-
1
background_color = Helpers::ResourceResolver.process_color(json_data['background'], required_imports)
-
1
color_params << "containerColor = #{background_color}"
-
end
-
-
4
if json_data['disabledBackground']
-
1
disabled_bg_color = Helpers::ResourceResolver.process_color(json_data['disabledBackground'], required_imports)
-
1
color_params << "disabledContainerColor = #{disabled_bg_color}"
-
end
-
-
4
if json_data['disabledFontColor']
-
1
disabled_font_color = Helpers::ResourceResolver.process_color(json_data['disabledFontColor'], required_imports)
-
1
color_params << "disabledContentColor = #{disabled_font_color}"
-
end
-
-
# Note: hilightColor (pressed state) isn't directly supported in Material3 ButtonDefaults
-
# We'd need a custom button implementation or InteractionSource for true pressed state
-
4
if json_data['hilightColor']
-
1
color_params << "// hilightColor: #{json_data['hilightColor']} - Use InteractionSource for pressed state"
-
end
-
-
4
if color_params.any?
-
8
colors_code += "\n" + color_params.map { |param| indent(param, depth + 2) }.join(",\n")
-
4
colors_code += "\n" + indent(")", depth + 1)
-
4
code += ",\n" + indent(colors_code, depth + 1)
-
end
-
end
-
-
# Handle enabled attribute
-
26
if json_data.key?('enabled')
-
3
if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
-
# Data binding for enabled
-
1
variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
-
1
code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
-
else
-
2
code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
-
end
-
end
-
-
26
code += "\n" + indent(") {", depth)
-
26
code += "\n" + indent("Text(#{text})", depth + 1)
-
-
# Apply text attributes if specified
-
26
if json_data['fontSize'] || json_data['fontColor']
-
2
text_code = "\n" + indent("Text(", depth + 1)
-
2
text_code += "\n" + indent("text = #{text},", depth + 2)
-
-
2
if json_data['fontSize']
-
1
text_code += "\n" + indent("fontSize = #{json_data['fontSize']}.sp,", depth + 2)
-
end
-
-
2
if json_data['fontColor']
-
1
color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
-
1
text_code += "\n" + indent("color = #{color_value},", depth + 2) if color_value
-
end
-
-
2
text_code += "\n" + indent(")", depth + 1)
-
2
code = code.sub(/Text\(#{Regexp.escape(text)}\)/, text_code.strip)
-
end
-
-
26
code += "\n" + indent("}", depth)
-
26
code
-
end
-
-
1
private
-
-
1
def self.indent(text, level)
-
164
return text if level == 0
-
85
spaces = ' ' * level
-
85
text.split("\n").map { |line|
-
93
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
# CheckBox Component Generator
-
# CheckBox is the primary component name. Check is supported as an alias for backward compatibility.
-
# Both "CheckBox" and "Check" JSON types map to this component.
-
1
class CheckboxComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
# CheckBox uses 'isOn', 'checked', or 'bind' for binding
-
# Priority: isOn > checked > bind
-
7
state_attr = json_data['isOn'] || json_data['checked']
-
7
checked = if state_attr
-
2
if state_attr.is_a?(String) && state_attr.match(/@\{([^}]+)\}/)
-
1
variable = $1
-
1
"data.#{variable}"
-
else
-
1
state_attr.to_s
-
end
-
5
elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
variable = $1
-
"data.#{variable}"
-
else
-
5
'false'
-
end
-
-
7
has_label = json_data['label'] || json_data['text']
-
7
has_custom_icon = json_data['icon'] || json_data['selectedIcon']
-
-
# If custom icons are specified, use IconToggleButton instead of Checkbox
-
7
if has_custom_icon
-
return generate_icon_checkbox(json_data, depth, required_imports, parent_type, checked)
-
end
-
-
7
if has_label
-
# Checkbox with label
-
code = indent("Row(", depth)
-
code += "\n" + indent("verticalAlignment = Alignment.CenterVertically,", depth + 1)
-
-
# Build modifiers for Row
-
modifiers = []
-
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
-
code += "\n" + indent(") {", depth)
-
-
# Checkbox
-
code += "\n" + indent("Checkbox(", depth + 1)
-
code += "\n" + indent("checked = #{checked},", depth + 2)
-
-
# onCheckedChange handler
-
binding_variable = nil
-
state_attr_val = json_data['isOn'] || json_data['checked']
-
if state_attr_val.is_a?(String) && state_attr_val.match(/@\{([^}]+)\}/)
-
binding_variable = $1
-
elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
binding_variable = $1
-
end
-
-
if json_data['onValueChange']
-
# onValueChange (camelCase) -> binding format only (@{functionName})
-
if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
-
method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
-
code += "\n" + indent("onCheckedChange = { viewModel.#{method_name}(it) }", depth + 2)
-
else
-
code += "\n" + indent("onCheckedChange = { // ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName} }", depth + 2)
-
end
-
elsif binding_variable
-
code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue)) }", depth + 2)
-
else
-
code += "\n" + indent("onCheckedChange = { }", depth + 2)
-
end
-
-
code += "\n" + indent(")", depth + 1)
-
-
# Spacer with configurable spacing
-
spacing = json_data['spacing'] || 8
-
code += "\n" + indent("Spacer(modifier = Modifier.width(#{spacing}.dp))", depth + 1)
-
-
# Label text with font attributes
-
label_text = json_data['label'] || json_data['text']
-
text_params = ["text = \"#{label_text}\""]
-
-
if json_data['fontSize']
-
text_params << "fontSize = #{json_data['fontSize']}.sp"
-
end
-
-
if json_data['fontColor']
-
font_color = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
-
text_params << "color = #{font_color}"
-
end
-
-
if json_data['font']
-
font_weight = json_data['font'].downcase == 'bold' ? 'FontWeight.Bold' : 'FontWeight.Normal'
-
text_params << "fontWeight = #{font_weight}"
-
end
-
-
if text_params.size == 1
-
code += "\n" + indent("Text(\"#{label_text}\")", depth + 1)
-
else
-
code += "\n" + indent("Text(", depth + 1)
-
code += "\n" + text_params.map { |param| indent(param, depth + 2) }.join(",\n")
-
code += "\n" + indent(")", depth + 1)
-
end
-
-
code += "\n" + indent("}", depth)
-
else
-
# Checkbox without label
-
7
code = indent("Checkbox(", depth)
-
7
code += "\n" + indent("checked = #{checked},", depth + 1)
-
-
# onCheckedChange handler
-
7
binding_variable = nil
-
7
state_attr_val = json_data['isOn'] || json_data['checked']
-
7
if state_attr_val.is_a?(String) && state_attr_val.match(/@\{([^}]+)\}/)
-
1
binding_variable = $1
-
6
elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
binding_variable = $1
-
end
-
-
7
if json_data['onValueChange']
-
# onValueChange (camelCase) -> binding format only (@{functionName})
-
if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
-
method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
-
code += "\n" + indent("onCheckedChange = { viewModel.#{method_name}(it) },", depth + 1)
-
else
-
code += "\n" + indent("onCheckedChange = { // ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName} },", depth + 1)
-
end
-
7
elsif binding_variable
-
1
code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue)) },", depth + 1)
-
else
-
6
code += "\n" + indent("onCheckedChange = { },", depth + 1)
-
end
-
-
# Build modifiers
-
7
modifiers = []
-
7
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
7
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
7
modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
-
-
# Add weight modifier if in Row or Column
-
7
if parent_type == 'Row' || parent_type == 'Column'
-
modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
-
end
-
-
7
code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
-
-
# Checkbox colors
-
7
if json_data['checkColor'] || json_data['uncheckedColor']
-
1
required_imports&.add(:checkbox_colors)
-
1
colors_params = []
-
-
1
if json_data['checkColor']
-
1
checked_color = Helpers::ResourceResolver.process_color(json_data['checkColor'], required_imports)
-
1
colors_params << "checkedColor = #{checked_color}"
-
end
-
-
1
if json_data['uncheckedColor']
-
unchecked_color = Helpers::ResourceResolver.process_color(json_data['uncheckedColor'], required_imports)
-
colors_params << "uncheckedColor = #{unchecked_color}"
-
end
-
-
1
if colors_params.any?
-
1
code += ",\n" + indent("colors = CheckboxDefaults.colors(", depth + 1)
-
2
code += "\n" + colors_params.map { |param| indent(param, depth + 2) }.join(",\n")
-
1
code += "\n" + indent(")", depth + 1)
-
end
-
end
-
-
# Handle enabled attribute
-
7
if json_data.key?('enabled')
-
if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
-
variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
-
code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
-
else
-
code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
-
end
-
end
-
-
7
code += "\n" + indent(")", depth)
-
end
-
-
7
code
-
end
-
-
1
private
-
-
# Generate checkbox with custom icon/selectedIcon
-
1
def self.generate_icon_checkbox(json_data, depth, required_imports, parent_type, checked)
-
required_imports&.add(:icon_toggle_button)
-
required_imports&.add(:icon)
-
-
icon = json_data['icon'] || 'check_box_outline_blank'
-
selected_icon = json_data['selectedIcon'] || 'check_box'
-
-
# Resolve icon names to drawable resources
-
icon_res = Helpers::ResourceResolver.process_drawable(icon, required_imports)
-
selected_icon_res = Helpers::ResourceResolver.process_drawable(selected_icon, required_imports)
-
-
code = indent("IconToggleButton(", depth)
-
code += "\n" + indent("checked = #{checked},", depth + 1)
-
-
# onCheckedChange handler
-
binding_variable = nil
-
state_attr_val = json_data['isOn'] || json_data['checked']
-
if state_attr_val.is_a?(String) && state_attr_val.match(/@\{([^}]+)\}/)
-
binding_variable = $1
-
elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
binding_variable = $1
-
end
-
-
if json_data['onValueChange']
-
# onValueChange (camelCase) -> binding format only (@{functionName})
-
if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
-
method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
-
code += "\n" + indent("onCheckedChange = { viewModel.#{method_name}(it) }", depth + 1)
-
else
-
code += "\n" + indent("onCheckedChange = { // ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName} }", depth + 1)
-
end
-
elsif binding_variable
-
code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue)) }", depth + 1)
-
else
-
code += "\n" + indent("onCheckedChange = { }", depth + 1)
-
end
-
-
# Build modifiers
-
modifiers = []
-
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
-
-
code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
-
-
code += "\n" + indent(") {", depth)
-
-
# Icon content - switch based on checked state
-
code += "\n" + indent("Icon(", depth + 1)
-
code += "\n" + indent("painter = painterResource(if (#{checked}) #{selected_icon_res} else #{icon_res}),", depth + 2)
-
code += "\n" + indent("contentDescription = null", depth + 2)
-
-
# Icon tint color
-
if json_data['fontColor']
-
icon_color = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
-
code += ",\n" + indent("tint = #{icon_color}", depth + 2)
-
end
-
-
code += "\n" + indent(")", depth + 1)
-
code += "\n" + indent("}", depth)
-
-
code
-
end
-
-
1
def self.indent(text, level)
-
31
return text if level == 0
-
17
spaces = ' ' * level
-
17
text.split("\n").map { |line|
-
17
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class CircleImageComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
# CircleImage can be local or network image
-
21
is_network = json_data['url'] || (json_data['source'] && json_data['source'].start_with?('http'))
-
-
21
if is_network
-
4
required_imports&.add(:async_image)
-
4
url = process_data_binding(json_data['url'] || json_data['source'] || json_data['src'] || '')
-
-
4
code = indent("AsyncImage(", depth)
-
4
code += "\n" + indent("model = #{url},", depth + 1)
-
else
-
# Local image
-
17
image_name = json_data['source'] || json_data['src'] || 'placeholder'
-
# Remove file extension and convert to resource name
-
17
resource_name = image_name.gsub('.png', '').gsub('.jpg', '').gsub('-', '_').downcase
-
-
17
code = indent("Image(", depth)
-
17
code += "\n" + indent("painter = painterResource(id = R.drawable.#{resource_name}),", depth + 1)
-
end
-
-
21
content_description = json_data['contentDescription'] || 'Profile Image'
-
21
code += "\n" + indent("contentDescription = \"#{content_description}\",", depth + 1)
-
-
# Content scale - typically Crop for circular images
-
21
required_imports&.add(:content_scale)
-
21
code += "\n" + indent("contentScale = ContentScale.Crop,", depth + 1)
-
-
# Build modifiers for circular shape
-
21
modifiers = []
-
-
# Size (use 'size' attribute or default to 48dp)
-
21
size = json_data['size'] || 48
-
21
modifiers << ".size(#{size}.dp)"
-
-
# Circular clip
-
21
required_imports&.add(:shape)
-
21
modifiers << ".clip(CircleShape)"
-
-
# Border for circle
-
21
if json_data['borderWidth'] && json_data['borderColor']
-
1
required_imports&.add(:border)
-
1
modifiers << ".border(#{json_data['borderWidth']}.dp, Helpers::ResourceResolver.process_color('#{json_data['borderColor']}', required_imports), CircleShape)"
-
end
-
-
# Background (in case image doesn't load)
-
21
if json_data['background']
-
1
required_imports&.add(:background)
-
1
modifiers << ".background(Helpers::ResourceResolver.process_color('#{json_data['background']}', required_imports))"
-
end
-
-
21
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
21
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
21
code += Helpers::ModifierBuilder.format(modifiers, depth)
-
-
# Error handling for network images
-
21
if is_network && json_data['errorImage']
-
1
code += ",\n" + indent("error = painterResource(R.drawable.#{json_data['errorImage'].gsub('.png', '').gsub('.jpg', '')})", depth + 1)
-
end
-
-
21
code += "\n" + indent(")", depth)
-
21
code
-
end
-
-
1
private
-
-
1
def self.process_data_binding(text)
-
7
return quote(text) unless text.is_a?(String)
-
-
7
if text.match(/@\{([^}]+)\}/)
-
2
variable = $1
-
2
"data.#{variable}"
-
else
-
5
quote(text)
-
end
-
end
-
-
1
def self.quote(text)
-
7
"\"#{text.gsub('"', '\\"')}\""
-
end
-
-
1
def self.indent(text, level)
-
108
return text if level == 0
-
65
spaces = ' ' * level
-
65
text.split("\n").map { |line|
-
65
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class CollectionComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
25
required_imports&.add(:lazy_grid)
-
25
required_imports&.add(:grid_item_span)
-
-
# Check if sections are defined
-
25
sections = json_data['sections'] || []
-
# Support both 'layout' and 'orientation' attributes for horizontal/vertical
-
25
layout = json_data['layout'] || json_data['orientation'] || 'vertical'
-
25
is_horizontal = layout == 'horizontal'
-
-
# Legacy: Extract cellClasses, headerClasses, footerClasses (string arrays)
-
25
cell_classes = json_data['cellClasses'] || []
-
25
header_classes = json_data['headerClasses'] || []
-
25
footer_classes = json_data['footerClasses'] || []
-
-
# Use the class names directly
-
25
cell_class_name = cell_classes.first if cell_classes.any?
-
25
header_class_name = header_classes.first if header_classes.any?
-
25
footer_class_name = footer_classes.first if footer_classes.any?
-
-
# Calculate the grid columns based on sections or default
-
25
default_columns = json_data['columns'] || 1
-
-
25
if sections.any?
-
# Collect all unique column counts from sections
-
24
section_columns = sections.map { |s| s['columns'] || default_columns }.uniq
-
-
# If sections have different column counts, use LCM
-
11
if section_columns.size > 1
-
1
columns = calculate_lcm(section_columns)
-
else
-
10
columns = section_columns.first
-
end
-
else
-
14
columns = default_columns
-
end
-
-
# Determine grid type based on layout
-
25
direction = is_horizontal ? 'horizontal' : 'vertical'
-
-
25
if direction == 'horizontal'
-
2
code = indent("LazyHorizontalGrid(", depth)
-
2
code += "\n" + indent("rows = GridCells.Fixed(#{columns}),", depth + 1)
-
else
-
23
code = indent("LazyVerticalGrid(", depth)
-
23
code += "\n" + indent("columns = GridCells.Fixed(#{columns}),", depth + 1)
-
end
-
-
# Content padding
-
25
if json_data['contentPadding']
-
2
padding = json_data['contentPadding']
-
2
if padding.is_a?(Array) && padding.length == 4
-
1
code += "\n" + indent("contentPadding = PaddingValues(top = #{padding[0]}.dp, end = #{padding[1]}.dp, bottom = #{padding[2]}.dp, start = #{padding[3]}.dp),", depth + 1)
-
1
elsif padding.is_a?(Numeric)
-
1
code += "\n" + indent("contentPadding = PaddingValues(#{padding}.dp),", depth + 1)
-
end
-
end
-
-
# Item spacing
-
# lineSpacing: vertical spacing between rows (minimumLineSpacing in iOS)
-
# columnSpacing: horizontal spacing between columns (minimumInteritemSpacing in iOS)
-
# itemSpacing/spacing: uniform spacing (fallback)
-
25
line_spacing = json_data['lineSpacing'] || json_data['itemSpacing'] || json_data['spacing']
-
25
column_spacing = json_data['columnSpacing'] || json_data['itemSpacing'] || json_data['spacing']
-
-
25
if line_spacing || column_spacing
-
2
required_imports&.add(:arrangement)
-
2
if line_spacing
-
2
code += "\n" + indent("verticalArrangement = Arrangement.spacedBy(#{line_spacing}.dp),", depth + 1)
-
end
-
2
if column_spacing
-
2
code += "\n" + indent("horizontalArrangement = Arrangement.spacedBy(#{column_spacing}.dp),", depth + 1)
-
end
-
end
-
-
# Build modifiers
-
25
modifiers = []
-
25
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
25
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
25
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
25
modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
-
-
25
code += Helpers::ModifierBuilder.format(modifiers, depth)
-
-
25
code += "\n" + indent(") {", depth)
-
-
# Check if sections are defined
-
25
if sections.any?
-
# Generate section-based collection
-
11
code += generate_sections_content(json_data, sections, columns, depth, required_imports)
-
14
elsif cell_class_name
-
# Check if items property is specified (e.g., "@{items}")
-
6
items_property = json_data['items']
-
-
6
if items_property && items_property.match(/@\{([^}]+)\}/)
-
# Extract property name from @{propertyName}
-
4
property_name = $1
-
-
# Items should be a Map<String, List<Any>> where key is cell class name
-
# Get the items for this specific cell class
-
4
code += "\n" + indent("// Collection with data source: #{property_name}[\"#{cell_class_name}\"]", depth + 1)
-
4
code += "\n" + indent("val cellItems = data.#{property_name}[\"#{cell_class_name}\"] ?: emptyList()", depth + 1)
-
4
code += "\n" + indent("items(cellItems.size) { index ->", depth + 1)
-
4
code += "\n" + indent("val item = cellItems[index]", depth + 2)
-
else
-
# Default to empty list
-
2
code += "\n" + indent("// Collection with no data source", depth + 1)
-
2
code += "\n" + indent("items(0) { index ->", depth + 1)
-
2
code += "\n" + indent("// No items", depth + 2)
-
end
-
-
# Create cell view with data
-
6
code += "\n" + indent("when (val itemData = item) {", depth + 2)
-
6
code += "\n" + indent("is #{cell_class_name}Data -> {", depth + 3)
-
6
code += "\n" + indent("#{cell_class_name}View(", depth + 4)
-
6
code += "\n" + indent("data = itemData,", depth + 5)
-
6
code += "\n" + indent("viewModel = viewModel(),", depth + 5)
-
6
code += "\n" + indent("modifier = Modifier", depth + 5)
-
-
# Cell-specific modifiers
-
6
if json_data['cellHeight']
-
1
code += "\n" + indent(" .height(#{json_data['cellHeight']}.dp)", depth + 5)
-
end
-
-
# For grid layouts, ensure cells expand to fill width
-
6
if columns > 1
-
1
code += "\n" + indent(" .fillMaxWidth()", depth + 5)
-
end
-
-
6
code += "\n" + indent(")", depth + 4)
-
6
code += "\n" + indent("}", depth + 3)
-
6
code += "\n" + indent("is Map<*, *> -> {", depth + 3)
-
6
code += "\n" + indent("// Convert map to data class", depth + 4)
-
6
code += "\n" + indent("val data = #{cell_class_name}Data.fromMap(itemData as Map<String, Any>)", depth + 4)
-
6
code += "\n" + indent("#{cell_class_name}View(", depth + 4)
-
6
code += "\n" + indent("data = data,", depth + 5)
-
6
code += "\n" + indent("viewModel = viewModel(),", depth + 5)
-
6
code += "\n" + indent("modifier = Modifier", depth + 5)
-
-
# Cell-specific modifiers
-
6
if json_data['cellHeight']
-
1
code += "\n" + indent(" .height(#{json_data['cellHeight']}.dp)", depth + 5)
-
end
-
-
# For grid layouts, ensure cells expand to fill width
-
6
if columns > 1
-
1
code += "\n" + indent(" .fillMaxWidth()", depth + 5)
-
end
-
-
6
code += "\n" + indent(")", depth + 4)
-
6
code += "\n" + indent("}", depth + 3)
-
6
code += "\n" + indent("else -> {", depth + 3)
-
6
code += "\n" + indent("// Unsupported item type", depth + 4)
-
6
code += "\n" + indent("}", depth + 3)
-
6
code += "\n" + indent("}", depth + 2)
-
6
code += "\n" + indent("}", depth + 1)
-
else
-
# No cell class specified - show placeholder
-
8
code += "\n" + indent("// No cellClasses specified", depth + 1)
-
8
code += "\n" + indent("items(10) { index ->", depth + 1)
-
8
code += "\n" + indent("Card(", depth + 2)
-
8
code += "\n" + indent("modifier = Modifier", depth + 3)
-
8
code += "\n" + indent(" .padding(4.dp)", depth + 3)
-
8
code += "\n" + indent(" .fillMaxWidth()", depth + 3)
-
8
code += "\n" + indent(" .height(80.dp)", depth + 3)
-
8
code += "\n" + indent(") {", depth + 2)
-
8
code += "\n" + indent("Box(", depth + 3)
-
8
code += "\n" + indent("modifier = Modifier.fillMaxSize(),", depth + 4)
-
8
code += "\n" + indent("contentAlignment = Alignment.Center", depth + 4)
-
8
code += "\n" + indent(") {", depth + 3)
-
8
code += "\n" + indent("Text(\"Item ${index}\")", depth + 4)
-
8
code += "\n" + indent("}", depth + 3)
-
8
code += "\n" + indent("}", depth + 2)
-
8
code += "\n" + indent("}", depth + 1)
-
end
-
-
25
code += "\n" + indent("}", depth)
-
25
code
-
end
-
-
1
def self.generate_sections_content(json_data, sections, grid_columns, depth, required_imports)
-
11
code = ""
-
11
items_property = json_data['items']
-
11
default_columns = json_data['columns'] || 1
-
-
# Check if we need GridItemSpan
-
# Need it for headers/footers or when sections have different column counts
-
24
has_headers_or_footers = sections.any? { |s| s['header'] || s['footer'] }
-
24
section_columns_vary = sections.map { |s| s['columns'] || default_columns }.uniq.size > 1
-
23
needs_span = sections.any? { |s| s['columns'] && s['columns'] != grid_columns }
-
-
11
if has_headers_or_footers || section_columns_vary || needs_span
-
3
required_imports&.add(:grid_item_span)
-
end
-
-
11
if items_property && items_property.match(/@\{([^}]+)\}/)
-
7
property_name = $1
-
-
# Generate sections with GridItemSpan for different column counts
-
7
sections.each_with_index do |section, index|
-
7
cell_view_name = section['cell']
-
7
section_columns = section['columns'] || default_columns
-
-
# Calculate the span for items in this section
-
7
item_span = grid_columns / section_columns
-
-
7
if cell_view_name
-
# Add cell view imports
-
7
required_imports&.add("cell:#{cell_view_name}")
-
-
# Add header import if exists
-
7
if section['header']
-
1
required_imports&.add("cell:#{section['header']}")
-
end
-
-
# Add footer import if exists
-
7
if section['footer']
-
1
required_imports&.add("cell:#{section['footer']}")
-
end
-
-
7
code += "\n" + indent("// Section #{index + 1}: #{cell_view_name} (#{section_columns} columns)", depth + 1)
-
7
code += "\n" + indent("data.#{property_name}.sections.getOrNull(#{index})?.let { section ->", depth + 1)
-
-
# Generate header if present
-
7
if section['header']
-
1
header_view_name = section['header']
-
1
code += "\n" + indent("// Section #{index + 1} Header: #{header_view_name}", depth + 2)
-
1
code += "\n" + indent("section.header?.let { headerData ->", depth + 2)
-
1
code += "\n" + indent("item(span = { GridItemSpan(maxLineSpan) }) {", depth + 3)
-
1
code += "\n" + indent("val data = #{header_view_name}Data.fromMap(headerData.data)", depth + 4)
-
1
code += "\n" + indent("#{header_view_name}View(", depth + 4)
-
1
code += "\n" + indent("data = data,", depth + 5)
-
1
code += "\n" + indent("viewModel = viewModel(),", depth + 5)
-
1
code += "\n" + indent("modifier = Modifier.fillMaxWidth()", depth + 5)
-
1
code += "\n" + indent(")", depth + 4)
-
1
code += "\n" + indent("}", depth + 3)
-
1
code += "\n" + indent("}", depth + 2)
-
end
-
-
# Generate cells
-
7
code += "\n" + indent("section.cells?.let { cellData ->", depth + 2)
-
7
if item_span > 1
-
code += "\n" + indent("items(cellData.data.size, span = { GridItemSpan(#{item_span}) }) { cellIndex ->", depth + 3)
-
else
-
7
code += "\n" + indent("items(cellData.data.size) { cellIndex ->", depth + 3)
-
end
-
7
code += "\n" + indent("val item = cellData.data[cellIndex]", depth + 4)
-
-
# Generate cell view instantiation
-
7
code += "\n" + indent("when (item) {", depth + 4)
-
7
code += "\n" + indent("is Map<*, *> -> {", depth + 5)
-
7
code += "\n" + indent("val data = #{cell_view_name}Data.fromMap(item as Map<String, Any>)", depth + 6)
-
7
code += "\n" + indent("#{cell_view_name}View(", depth + 6)
-
7
code += "\n" + indent("data = data,", depth + 7)
-
7
code += "\n" + indent("viewModel = viewModel(),", depth + 7)
-
7
code += "\n" + indent("modifier = Modifier", depth + 7)
-
-
# Add modifiers
-
7
if json_data['cellWidth'] && json_data['layout'] == 'horizontal'
-
1
code += "\n" + indent(" .width(#{json_data['cellWidth']}.dp)", depth + 7)
-
6
elsif json_data['cellHeight']
-
1
code += "\n" + indent(" .height(#{json_data['cellHeight']}.dp)", depth + 7)
-
end
-
-
7
if section_columns > 1 && json_data['layout'] != 'horizontal'
-
3
code += "\n" + indent(" .fillMaxWidth()", depth + 7)
-
end
-
-
7
code += "\n" + indent(")", depth + 6)
-
7
code += "\n" + indent("}", depth + 5)
-
7
code += "\n" + indent("else -> {", depth + 5)
-
7
code += "\n" + indent("// Unsupported item type", depth + 6)
-
7
code += "\n" + indent("}", depth + 5)
-
7
code += "\n" + indent("}", depth + 4)
-
7
code += "\n" + indent("}", depth + 3)
-
7
code += "\n" + indent("}", depth + 2)
-
-
# Generate footer if present
-
7
if section['footer']
-
1
footer_view_name = section['footer']
-
1
code += "\n" + indent("// Section #{index + 1} Footer: #{footer_view_name}", depth + 2)
-
1
code += "\n" + indent("section.footer?.let { footerData ->", depth + 2)
-
1
code += "\n" + indent("item(span = { GridItemSpan(maxLineSpan) }) {", depth + 3)
-
1
code += "\n" + indent("val data = #{footer_view_name}Data.fromMap(footerData.data)", depth + 4)
-
1
code += "\n" + indent("#{footer_view_name}View(", depth + 4)
-
1
code += "\n" + indent("data = data,", depth + 5)
-
1
code += "\n" + indent("viewModel = viewModel(),", depth + 5)
-
1
code += "\n" + indent("modifier = Modifier.fillMaxWidth()", depth + 5)
-
1
code += "\n" + indent(")", depth + 4)
-
1
code += "\n" + indent("}", depth + 3)
-
1
code += "\n" + indent("}", depth + 2)
-
end
-
-
7
code += "\n" + indent("}", depth + 1)
-
end
-
end
-
else
-
4
code += "\n" + indent("// No items binding specified", depth + 1)
-
end
-
-
11
code
-
end
-
-
1
private
-
-
1
def self.calculate_lcm(numbers)
-
11
numbers.reduce(1) { |lcm, n| lcm.lcm(n) }
-
end
-
-
1
def self.extract_view_name(class_name)
-
4
return nil unless class_name
-
-
# Convert cell class name to Compose view name
-
# Remove common suffixes and add appropriate naming
-
3
view_name = class_name
-
-
# Remove common UIKit/Android suffixes
-
3
view_name = view_name.sub(/CollectionViewCell$/, '')
-
3
view_name = view_name.sub(/Cell$/, '')
-
3
view_name = view_name.sub(/cell$/, '')
-
-
# Convert to proper case and add View suffix if needed
-
3
view_name = to_pascal_case(view_name)
-
3
view_name += 'View' unless view_name.end_with?('View')
-
-
3
view_name
-
end
-
-
1
def self.to_pascal_case(str)
-
7
return str if str.nil? || str.empty?
-
-
# Handle snake_case or kebab-case to PascalCase
-
5
parts = str.split(/[_-]/)
-
5
parts.map(&:capitalize).join
-
end
-
-
1
def self.indent(text, level)
-
573
return text if level == 0
-
497
spaces = ' ' * level
-
497
text.split("\n").map { |line|
-
499
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class ConstraintLayoutComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
6
required_imports&.add(:constraint_layout)
-
-
# Check if any child has relative positioning attributes
-
6
children = json_data['child'] || []
-
6
children = [children] unless children.is_a?(Array)
-
-
11
has_constraints = children.any? { |child| has_relative_positioning?(child) }
-
-
6
if has_constraints
-
4
generate_constraint_layout(json_data, children, depth, required_imports)
-
else
-
# Fall back to regular Box/Column/Row
-
2
Components::ContainerComponent.generate(json_data, depth, required_imports)
-
end
-
end
-
-
1
private
-
-
1
def self.has_relative_positioning?(component)
-
19
return false unless component.is_a?(Hash)
-
-
17
relative_attrs = [
-
'alignTopOfView', 'alignBottomOfView', 'alignLeftOfView', 'alignRightOfView',
-
'alignTopView', 'alignBottomView', 'alignLeftView', 'alignRightView',
-
'alignCenterVerticalView', 'alignCenterHorizontalView',
-
'alignTop', 'alignBottom', 'alignLeft', 'alignRight',
-
'centerHorizontal', 'centerVertical', 'centerInParent'
-
]
-
-
239
relative_attrs.any? { |attr| component[attr] }
-
end
-
-
1
def self.has_positioning_constraints?(component)
-
19
return false unless component.is_a?(Hash)
-
-
# These are constraints that use margins in linkTo()
-
# For alignXxxView, margins should be applied as padding modifiers
-
# For alignTop/Bottom/Left/Right to parent, margins are applied in linkTo()
-
18
positioning_attrs = [
-
'alignTopOfView', 'alignBottomOfView', 'alignLeftOfView', 'alignRightOfView',
-
'alignTopView', 'alignBottomView', 'alignLeftView', 'alignRightView',
-
'alignCenterVerticalView', 'alignCenterHorizontalView',
-
'alignTop', 'alignBottom', 'alignLeft', 'alignRight'
-
]
-
-
# centerInParent, centerHorizontal, centerVertical don't use margins in linkTo()
-
# so they should still apply margins as padding
-
245
positioning_attrs.any? { |attr| component[attr] }
-
end
-
-
1
def self.should_apply_margins_as_padding?(component)
-
16
return false unless component.is_a?(Hash)
-
-
# Don't apply margins as padding if they're already handled in linkTo()
-
# All positioning constraints now handle margins in linkTo()
-
15
return !has_positioning_constraints?(component)
-
end
-
-
1
def self.generate_constraint_layout(json_data, children, depth, required_imports)
-
4
code = indent("ConstraintLayout(", depth)
-
-
# Build modifiers
-
4
modifiers = []
-
4
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
4
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
4
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
4
modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
-
-
4
code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
-
4
code += "\n" + indent(") {", depth)
-
-
# Create constraint references
-
4
constraint_refs = []
-
4
children.each_with_index do |child, index|
-
4
if child.is_a?(Hash) && (child['id'] || has_relative_positioning?(child))
-
4
ref_name = child['id'] || "view_#{index}"
-
4
code += "\n" + indent("val #{ref_name} = createRef()", depth + 1)
-
4
constraint_refs << ref_name
-
end
-
end
-
-
4
code += "\n" if constraint_refs.any?
-
-
# Generate children with constraints
-
4
children.each_with_index do |child, index|
-
4
if child.is_a?(Hash)
-
4
ref_name = child['id'] || "view_#{index}"
-
-
# Generate the child component
-
4
child_code = generate_child_with_constraints(child, ref_name, depth + 1, required_imports)
-
4
code += "\n" + child_code unless child_code.empty?
-
end
-
end
-
-
4
code += "\n" + indent("}", depth)
-
4
code
-
end
-
-
1
def self.generate_child_with_constraints(child_data, ref_name, depth, required_imports)
-
# Get the component type
-
4
component_type = child_data['type'] || 'View'
-
-
# Generate the component code based on type
-
4
component_code = case component_type
-
when 'Text', 'Label'
-
3
generate_text_component(child_data, depth, required_imports)
-
when 'Button'
-
1
generate_button_component(child_data, depth, required_imports)
-
when 'Image'
-
generate_image_component(child_data, depth, required_imports)
-
else
-
generate_box_component(child_data, depth, required_imports)
-
end
-
-
# Add constrainAs modifier
-
4
constraints = Helpers::ModifierBuilder.build_relative_positioning(child_data)
-
-
# Always add constrainAs for all children in ConstraintLayout
-
# Insert constrainAs modifier
-
4
constraint_content = if constraints.any?
-
12
constraints.map { |c| indent(c, depth + 2) }.join("\n")
-
else
-
"" # Empty constraint block
-
end
-
-
# Find where to insert the constrainAs modifier
-
4
if component_code.include?("modifier = Modifier")
-
# Replace existing modifier with constrainAs
-
component_code.sub!(/modifier = Modifier(.*?)(?=,\n|\n)/m) do |match|
-
existing_modifiers = $1
-
"modifier = Modifier.constrainAs(#{ref_name}) {\n#{constraint_content}\n" + indent("}", depth + 1) + existing_modifiers
-
end
-
else
-
# Add new modifier after the opening parenthesis
-
4
insert_pos = component_code.index("(") + 1
-
4
modifier_code = "\n" + indent("modifier = Modifier.constrainAs(#{ref_name}) {", depth + 1)
-
4
if constraint_content.length > 0
-
4
modifier_code += "\n#{constraint_content}"
-
end
-
4
modifier_code += "\n" + indent("}", depth + 1) + ","
-
4
component_code.insert(insert_pos, modifier_code)
-
end
-
-
4
component_code
-
end
-
-
1
def self.generate_text_component(data, depth, required_imports)
-
13
text = data['text'] || ''
-
# Check for data binding
-
13
if text.start_with?('@{')
-
1
variable_name = text[2..-2]
-
1
escaped_text = "\"${data.#{variable_name}}\""
-
else
-
12
escaped_text = quote(text)
-
end
-
-
13
code = indent("Text(", depth)
-
-
# Add modifier with constraints
-
# In ConstraintLayout:
-
# - If element has relative positioning constraints, margins are handled ONLY in linkTo()
-
# - If element has no constraints (just centerInParent etc), margins are applied as padding
-
13
modifiers = []
-
-
# Apply margins BEFORE size so they act as outer spacing
-
# This ensures the size is the actual content size, not reduced by margins
-
13
if should_apply_margins_as_padding?(data)
-
11
modifiers.concat(Helpers::ModifierBuilder.build_margins(data))
-
end
-
-
13
modifiers.concat(Helpers::ModifierBuilder.build_size(data))
-
13
modifiers.concat(Helpers::ModifierBuilder.build_background(data, required_imports))
-
13
modifiers.concat(Helpers::ModifierBuilder.build_padding(data))
-
-
13
if modifiers.any?
-
code += "\n" + indent("modifier = Modifier", depth + 1)
-
modifiers.each do |mod|
-
code += "\n" + indent(" #{mod}", depth + 1)
-
end
-
code += ","
-
end
-
-
13
code += "\n" + indent("text = #{escaped_text}", depth + 1)
-
-
13
if data['fontSize']
-
1
code += ",\n" + indent("fontSize = #{data['fontSize']}.sp", depth + 1)
-
end
-
-
13
if data['fontColor'] || data['color']
-
2
color = data['fontColor'] || data['color']
-
2
color_resolved = Helpers::ResourceResolver.process_color(color, required_imports)
-
2
code += ",\n" + indent("color = #{color_resolved}", depth + 1)
-
end
-
-
13
if data['font'] == 'bold' || data['fontWeight'] == 'bold'
-
2
required_imports&.add(:font_weight)
-
2
code += ",\n" + indent("fontWeight = FontWeight.Bold", depth + 1)
-
end
-
-
13
if data['textAlign']
-
3
required_imports&.add(:text_align)
-
3
align = case data['textAlign']
-
1
when 'center' then 'TextAlign.Center'
-
1
when 'left' then 'TextAlign.Left'
-
1
when 'right' then 'TextAlign.Right'
-
else 'TextAlign.Start'
-
end
-
3
code += ",\n" + indent("textAlign = #{align}", depth + 1)
-
end
-
-
13
code += "\n" + indent(")", depth)
-
13
code
-
end
-
-
1
def self.generate_button_component(data, depth, required_imports)
-
5
text = data['text'] || 'Button'
-
# Properly escape text
-
5
escaped_text = quote(text)
-
-
5
code = indent("Button(", depth)
-
-
# onclick (lowercase) -> selector format only
-
# onClick (camelCase) -> binding format only
-
5
if data['onclick']
-
1
handler_call = Helpers::ModifierBuilder.get_event_handler_call(data['onclick'], is_camel_case: false)
-
1
code += "\n" + indent("onClick = { #{handler_call} }", depth + 1)
-
4
elsif data['onClick']
-
handler_call = Helpers::ModifierBuilder.get_event_handler_call(data['onClick'], is_camel_case: true)
-
code += "\n" + indent("onClick = { #{handler_call} }", depth + 1)
-
else
-
4
code += "\n" + indent("onClick = { }", depth + 1)
-
end
-
-
5
code += "\n" + indent(") {", depth)
-
5
code += "\n" + indent("Text(#{escaped_text})", depth + 1)
-
5
code += "\n" + indent("}", depth)
-
5
code
-
end
-
-
1
def self.generate_image_component(data, depth, required_imports)
-
4
source = data['src'] || data['source'] || 'placeholder'
-
-
4
code = indent("Image(", depth)
-
4
code += "\n" + indent("painter = painterResource(R.drawable.#{source.gsub('.png', '').gsub('.jpg', '')}),", depth + 1)
-
4
code += "\n" + indent("contentDescription = \"Image\"", depth + 1)
-
4
code += "\n" + indent(")", depth)
-
4
code
-
end
-
-
1
def self.generate_box_component(data, depth, required_imports)
-
1
code = indent("Box(", depth)
-
1
code += "\n" + indent(") {", depth)
-
1
code += "\n" + indent("// Content", depth + 1)
-
1
code += "\n" + indent("}", depth)
-
1
code
-
end
-
-
1
def self.quote(text)
-
# Escape special characters properly
-
20
escaped = text.gsub('\\', '\\\\\\\\') # Escape backslashes first
-
.gsub('"', '\\"') # Escape quotes
-
.gsub("\n", '\\n') # Escape newlines
-
.gsub("\r", '\\r') # Escape carriage returns
-
.gsub("\t", '\\t') # Escape tabs
-
20
"\"#{escaped}\""
-
end
-
-
1
def self.indent(text, level)
-
127
return text if level == 0
-
71
spaces = ' ' * level
-
71
text.split("\n").map { |line|
-
73
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative 'constraintlayout_component'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class ContainerComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
22
container_type = json_data['type'] || 'View'
-
22
orientation = json_data['orientation']
-
-
# Check if any child has relative positioning
-
22
children = json_data['child'] || []
-
22
children = [children] unless children.is_a?(Array)
-
-
22
if has_relative_positioning?(children)
-
# Use ConstraintLayout for relative positioning
-
return ConstraintLayoutComponent.generate(json_data, depth, required_imports)
-
end
-
-
# Determine layout type
-
22
layout = determine_layout(container_type, orientation)
-
-
22
code = indent("#{layout}(", depth)
-
-
# Build modifiers (correct order for Compose)
-
22
modifiers = []
-
-
# Add weight modifier if in Row or Column
-
22
if parent_type == 'Row' || parent_type == 'Column'
-
modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
-
end
-
-
# 1. Size first (total size including padding)
-
22
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
-
# 2. Margins (outer spacing)
-
22
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
# 3. Background (before padding so padding creates space inside)
-
22
modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
-
-
# 4. Padding (inner spacing) - applied last
-
22
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
-
22
code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
-
-
# Add gravity settings
-
22
if json_data['gravity']
-
6
code += add_gravity_settings(layout, json_data['gravity'], depth)
-
end
-
-
# Add direction settings
-
# Note: reverseLayout is only supported by LazyColumn/LazyRow, not Column/Row
-
# For regular Row/Column, we need to manually reverse the children order
-
22
if json_data['direction'] && (layout == 'Column' || layout == 'Row')
-
# Direction handling will be done by reversing children order
-
# No reverseLayout parameter for regular Row/Column
-
end
-
-
# Add spacing for Column/Row
-
22
if json_data['spacing'] && (layout == 'Column' || layout == 'Row')
-
2
required_imports&.add(:arrangement)
-
2
code += ",\n" + indent("verticalArrangement = Arrangement.spacedBy(#{json_data['spacing']}.dp)", depth + 1) if layout == 'Column'
-
2
code += ",\n" + indent("horizontalArrangement = Arrangement.spacedBy(#{json_data['spacing']}.dp)", depth + 1) if layout == 'Row'
-
end
-
-
# Add distribution for Column/Row
-
22
if json_data['distribution'] && (layout == 'Column' || layout == 'Row')
-
3
required_imports&.add(:arrangement)
-
-
3
arrangement = case json_data['distribution']
-
when 'fillEqually'
-
1
'Arrangement.SpaceEvenly'
-
when 'fill'
-
1
'Arrangement.SpaceBetween'
-
when 'equalSpacing'
-
1
'Arrangement.SpaceAround'
-
when 'equalCentering'
-
'Arrangement.SpaceEvenly'
-
else
-
nil
-
end
-
-
3
if arrangement
-
3
code += ",\n" + indent("verticalArrangement = #{arrangement}", depth + 1) if layout == 'Column'
-
3
code += ",\n" + indent("horizontalArrangement = #{arrangement}", depth + 1) if layout == 'Row'
-
end
-
end
-
-
22
code += "\n" + indent(") {", depth)
-
-
# Process children
-
22
children = json_data['child'] || []
-
22
children = [children] unless children.is_a?(Array)
-
-
# Reverse children order if direction requires it
-
22
if json_data['direction']
-
2
case json_data['direction']
-
when 'bottomToTop'
-
1
children = children.reverse if layout == 'Column'
-
when 'rightToLeft'
-
1
children = children.reverse if layout == 'Row'
-
end
-
end
-
-
# Return structure for parent to process children
-
22
{ code: code, children: children, closing: "\n" + indent("}", depth), layout_type: layout, json_data: json_data }
-
end
-
-
1
private
-
-
1
def self.has_relative_positioning?(children)
-
25
relative_attrs = [
-
'alignTopOfView', 'alignBottomOfView', 'alignLeftOfView', 'alignRightOfView',
-
'alignTopView', 'alignBottomView', 'alignLeftView', 'alignRightView',
-
'alignCenterVerticalView', 'alignCenterHorizontalView'
-
]
-
-
25
children.any? do |child|
-
12
next false unless child.is_a?(Hash)
-
101
relative_attrs.any? { |attr| child[attr] }
-
end
-
end
-
-
1
def self.determine_layout(container_type, orientation)
-
# SwiftJsonUI only has 'View' type, not VStack/HStack/ZStack
-
# Layout is determined by orientation attribute:
-
# - orientation: "vertical" → Column (VStack)
-
# - orientation: "horizontal" → Row (HStack)
-
# - no orientation → Box (ZStack)
-
-
26
if container_type == 'View'
-
23
if orientation == 'vertical'
-
12
'Column'
-
11
elsif orientation == 'horizontal'
-
7
'Row'
-
else
-
4
'Box'
-
end
-
else
-
# For other types (shouldn't happen with proper View type)
-
3
'Box'
-
end
-
end
-
-
1
def self.add_gravity_settings(layout, gravity, depth)
-
6
code = ""
-
-
6
if layout == 'Column'
-
4
case gravity
-
when 'top'
-
1
code += ",\n" + indent("verticalArrangement = Arrangement.Top", depth + 1)
-
when 'bottom'
-
1
code += ",\n" + indent("verticalArrangement = Arrangement.Bottom", depth + 1)
-
when 'centerVertical'
-
1
code += ",\n" + indent("verticalArrangement = Arrangement.Center", depth + 1)
-
when 'left'
-
code += ",\n" + indent("horizontalAlignment = Alignment.Start", depth + 1)
-
when 'right'
-
code += ",\n" + indent("horizontalAlignment = Alignment.End", depth + 1)
-
when 'centerHorizontal'
-
1
code += ",\n" + indent("horizontalAlignment = Alignment.CenterHorizontally", depth + 1)
-
end
-
2
elsif layout == 'Row'
-
2
case gravity
-
when 'left'
-
1
code += ",\n" + indent("horizontalArrangement = Arrangement.Start", depth + 1)
-
when 'right'
-
code += ",\n" + indent("horizontalArrangement = Arrangement.End", depth + 1)
-
when 'centerHorizontal'
-
code += ",\n" + indent("horizontalArrangement = Arrangement.Center", depth + 1)
-
when 'top'
-
code += ",\n" + indent("verticalAlignment = Alignment.Top", depth + 1)
-
when 'bottom'
-
code += ",\n" + indent("verticalAlignment = Alignment.Bottom", depth + 1)
-
when 'centerVertical'
-
1
code += ",\n" + indent("verticalAlignment = Alignment.CenterVertically", depth + 1)
-
end
-
end
-
-
6
code
-
end
-
-
1
def self.indent(text, level)
-
77
return text if level == 0
-
11
spaces = ' ' * level
-
11
text.split("\n").map { |line|
-
11
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class GradientviewComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
# GradientView maps to a Box with gradient background
-
4
code = indent("Box(", depth)
-
-
# Build modifiers
-
4
modifiers = []
-
4
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
4
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
4
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
# Add gradient background
-
# Support both 'colors' and 'items' for color list
-
4
colors = json_data['colors'] || json_data['items'] || ['#000000', '#FFFFFF']
-
-
# Determine gradient direction from orientation or start/end points
-
4
gradient_type = if json_data['orientation']
-
case json_data['orientation']
-
when 'horizontal'
-
'horizontalGradient'
-
when 'vertical'
-
'verticalGradient'
-
when 'diagonal'
-
'linearGradient'
-
else
-
'verticalGradient'
-
end
-
else
-
4
start_point = json_data['startPoint'] || 'top'
-
4
end_point = json_data['endPoint'] || 'bottom'
-
4
case [start_point, end_point]
-
when ['top', 'bottom'], ['bottom', 'top']
-
4
'verticalGradient'
-
when ['left', 'right'], ['leading', 'trailing'], ['right', 'left'], ['trailing', 'leading']
-
'horizontalGradient'
-
else
-
'linearGradient'
-
end
-
end
-
-
# Build color list - process colors at generation time, not runtime
-
4
color_list = colors.map { |color|
-
8
Helpers::ResourceResolver.process_color(color, required_imports)
-
}.join(", ")
-
-
# Add gradient modifier
-
4
required_imports&.add(:gradient)
-
4
modifiers << ".background(Brush.#{gradient_type}(listOf(#{color_list})))"
-
-
# Add corner radius if specified
-
4
if json_data['cornerRadius']
-
required_imports&.add(:shape)
-
modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
-
end
-
-
4
modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
-
-
4
code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
-
4
code += "\n" + indent(") {", depth)
-
-
# Process children
-
4
children = json_data['child'] || []
-
4
children = [children] unless children.is_a?(Array)
-
-
# Return structure for parent to process children
-
4
{ code: code, children: children, closing: "\n" + indent("}", depth), json_data: json_data }
-
end
-
-
1
private
-
-
1
def self.indent(text, level)
-
12
return text if level == 0
-
spaces = ' ' * level
-
text.split("\n").map { |line|
-
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class ImageComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
# 'src' is the official attribute for images per wiki
-
10
raw_src = json_data['src'] || 'placeholder'
-
-
# Add required imports
-
10
required_imports&.add(:image)
-
-
10
code = indent("Image(", depth)
-
-
# Check if src is a binding expression
-
10
if Helpers::ModifierBuilder.is_binding?(raw_src)
-
# @{mapTabIcon} -> viewModel.data.mapTabIcon (expects Painter type in Data)
-
property_name = Helpers::ModifierBuilder.extract_binding_property(raw_src)
-
camel_case_name = to_camel_case(property_name)
-
# Binding case doesn't need painterResource import since Data provides Painter directly
-
code += "\n" + indent("painter = viewModel.data.#{camel_case_name},", depth + 1)
-
else
-
# Static resource name needs painterResource
-
10
required_imports&.add(:painter_resource)
-
10
required_imports&.add(:r_class)
-
10
code += "\n" + indent("painter = painterResource(id = R.drawable.#{raw_src}),", depth + 1)
-
end
-
-
# Content description for accessibility
-
10
content_desc = json_data['contentDescription'] || ''
-
10
code += "\n" + indent("contentDescription = #{quote(content_desc)},", depth + 1)
-
-
# Build modifiers
-
10
modifiers = []
-
-
# Size handling
-
10
if json_data['width'] && json_data['height']
-
1
modifiers << ".size(#{json_data['width']}.dp, #{json_data['height']}.dp)"
-
9
elsif json_data['size']
-
1
modifiers << ".size(#{json_data['size']}.dp)"
-
else
-
8
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
end
-
-
10
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
10
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
10
code += Helpers::ModifierBuilder.format(modifiers, depth)
-
-
# Content mode
-
10
if json_data['contentMode']
-
3
required_imports&.add(:content_scale)
-
3
case json_data['contentMode'].downcase
-
when 'aspectfill'
-
1
code += ",\n" + indent("contentScale = ContentScale.Crop", depth + 1)
-
when 'aspectfit'
-
1
code += ",\n" + indent("contentScale = ContentScale.Fit", depth + 1)
-
when 'center'
-
1
code += ",\n" + indent("contentScale = ContentScale.None", depth + 1)
-
end
-
end
-
-
10
code += "\n" + indent(")", depth)
-
10
code
-
end
-
-
1
private
-
-
1
def self.to_camel_case(snake_case_string)
-
return snake_case_string unless snake_case_string.include?('_')
-
parts = snake_case_string.split('_')
-
parts[0] + parts[1..-1].map(&:capitalize).join
-
end
-
-
1
def self.quote(text)
-
10
"\"#{text.gsub('"', '\\"')}\""
-
end
-
-
1
def self.indent(text, level)
-
43
return text if level == 0
-
23
spaces = ' ' * level
-
23
text.split("\n").map { |line|
-
23
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class IndicatorComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
# Indicator can be circular or linear based on style
-
3
style = json_data['style'] || 'medium'
-
3
is_animating = json_data['animating']
-
-
# Check if animating is controlled by data binding
-
3
show_condition = if is_animating && is_animating.is_a?(String) && is_animating.match(/@\{([^}]+)\}/)
-
variable = $1
-
"data.#{variable}"
-
3
elsif is_animating == false
-
'false'
-
else
-
3
'true'
-
end
-
-
# Wrap in if condition if controlled by animating attribute
-
3
if is_animating != nil
-
code = indent("if (#{show_condition}) {", depth)
-
actual_depth = depth + 1
-
else
-
3
code = ""
-
3
actual_depth = depth
-
end
-
-
# Determine indicator type based on style
-
3
if style == 'linear'
-
code += "\n" if is_animating != nil
-
code += indent("LinearProgressIndicator(", actual_depth)
-
else
-
3
code += "\n" if is_animating != nil
-
3
code += indent("CircularProgressIndicator(", actual_depth)
-
end
-
-
# Build modifiers
-
3
modifiers = []
-
-
# Size based on style
-
3
if style == 'large'
-
modifiers << ".size(48.dp)"
-
3
elsif style == 'small'
-
modifiers << ".size(16.dp)"
-
3
elsif json_data['size']
-
modifiers << ".size(#{json_data['size']}.dp)"
-
end
-
-
3
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
3
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
3
modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
-
-
# Add weight modifier if in Row or Column
-
3
if parent_type == 'Row' || parent_type == 'Column'
-
modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
-
end
-
-
3
code += Helpers::ModifierBuilder.format(modifiers, actual_depth) if modifiers.any?
-
-
# Color
-
3
if json_data['color']
-
color_resolved = Helpers::ResourceResolver.process_color(json_data['color'], required_imports)
-
code += ",\n" + indent("color = #{color_resolved}", actual_depth + 1)
-
end
-
-
# Track color for linear progress
-
3
if style == 'linear' && json_data['trackColor']
-
trackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['trackColor'], required_imports)
-
code += ",\n" + indent("trackColor = #{trackcolor_resolved}", actual_depth + 1)
-
end
-
-
# Stroke width for circular progress
-
3
if style != 'linear' && json_data['strokeWidth']
-
code += ",\n" + indent("strokeWidth = #{json_data['strokeWidth']}.dp", actual_depth + 1)
-
end
-
-
3
code += "\n" + indent(")", actual_depth)
-
-
# Close if condition
-
3
if is_animating != nil
-
code += "\n" + indent("}", depth)
-
end
-
-
3
code
-
end
-
-
1
private
-
-
1
def self.indent(text, level)
-
6
return text if level == 0
-
spaces = ' ' * level
-
text.split("\n").map { |line|
-
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class NetworkImageComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
5
required_imports&.add(:async_image)
-
-
# NetworkImage uses 'source' or 'url' for image URL
-
5
url = process_data_binding(json_data['source'] || json_data['url'] || json_data['src'] || '')
-
# Support both 'hint' (primary) and 'placeholder' (alias)
-
5
placeholder = json_data['hint'] || json_data['placeholder']
-
5
content_description = json_data['contentDescription'] || 'Image'
-
-
5
code = indent("AsyncImage(", depth)
-
5
code += "\n" + indent("model = #{url},", depth + 1)
-
5
code += "\n" + indent("contentDescription = \"#{content_description}\",", depth + 1)
-
-
# Content scale
-
5
if json_data['contentMode']
-
required_imports&.add(:content_scale)
-
scale = case json_data['contentMode']
-
when 'aspectFit'
-
'ContentScale.Fit'
-
when 'aspectFill'
-
'ContentScale.Crop'
-
when 'fill', 'scaleToFill'
-
'ContentScale.FillBounds'
-
when 'center'
-
'ContentScale.None'
-
else
-
'ContentScale.Fit'
-
end
-
code += "\n" + indent("contentScale = #{scale},", depth + 1)
-
end
-
-
# Placeholder
-
5
if placeholder
-
1
code += "\n" + indent("placeholder = painterResource(R.drawable.#{placeholder.gsub('.png', '').gsub('.jpg', '')}),", depth + 1)
-
end
-
-
# Build modifiers
-
5
modifiers = []
-
-
# Handle size
-
5
if json_data['size']
-
# size is a single value for both width and height
-
modifiers << ".size(#{json_data['size']}.dp)"
-
else
-
5
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
end
-
-
# Corner radius for rounded images
-
5
if json_data['cornerRadius']
-
required_imports&.add(:shape)
-
modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
-
end
-
-
# Border
-
5
if json_data['borderWidth'] && json_data['borderColor']
-
required_imports&.add(:border)
-
shape = json_data['cornerRadius'] ? "RoundedCornerShape(#{json_data['cornerRadius']}.dp)" : "RectangleShape"
-
modifiers << ".border(#{json_data['borderWidth']}.dp, Helpers::ResourceResolver.process_color('#{json_data['borderColor']}', required_imports), #{shape})"
-
end
-
-
5
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
5
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
5
modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
-
-
5
code += Helpers::ModifierBuilder.format(modifiers, depth)
-
-
# Error handling
-
5
if json_data['errorImage']
-
code += ",\n" + indent("error = painterResource(R.drawable.#{json_data['errorImage'].gsub('.png', '').gsub('.jpg', '')})", depth + 1)
-
end
-
-
5
code += "\n" + indent(")", depth)
-
5
code
-
end
-
-
1
private
-
-
1
def self.process_data_binding(text)
-
5
return quote(text) unless text.is_a?(String)
-
-
5
if text.match(/@\{([^}]+)\}/)
-
1
variable = $1
-
1
"data.#{variable}"
-
else
-
4
quote(text)
-
end
-
end
-
-
1
def self.quote(text)
-
4
"\"#{text.gsub('"', '\\"')}\""
-
end
-
-
1
def self.indent(text, level)
-
21
return text if level == 0
-
11
spaces = ' ' * level
-
11
text.split("\n").map { |line|
-
11
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class ProgressComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
# Progress can have a value (determinate) or be indeterminate
-
15
has_value = json_data['value'] || json_data['bind']
-
-
15
if has_value
-
# Determinate progress (LinearProgressIndicator)
-
3
value = if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
1
variable = $1
-
1
"data.#{variable}.toFloat()"
-
2
elsif json_data['value'] && json_data['value'].match(/@\{([^}]+)\}/)
-
1
variable = $1
-
1
"data.#{variable}.toFloat()"
-
1
elsif json_data['value']
-
1
"#{json_data['value']}f"
-
else
-
'0f'
-
end
-
-
3
code = indent("LinearProgressIndicator(", depth)
-
3
code += "\n" + indent("progress = { #{value} },", depth + 1)
-
else
-
# Indeterminate progress
-
12
style = json_data['style'] || 'linear'
-
-
12
if style == 'circular' || style == 'large'
-
2
code = indent("CircularProgressIndicator(", depth)
-
else
-
10
code = indent("LinearProgressIndicator(", depth)
-
end
-
end
-
-
# Build modifiers
-
15
modifiers = []
-
15
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
15
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
15
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
15
code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
-
-
# Progress colors
-
15
if json_data['progressTintColor'] || json_data['trackTintColor']
-
3
colors_params = []
-
-
3
if json_data['progressTintColor']
-
2
color_resolved = Helpers::ResourceResolver.process_color(json_data['progressTintColor'], required_imports)
-
2
colors_params << "color = #{color_resolved}"
-
end
-
-
3
if json_data['trackTintColor']
-
2
trackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['trackTintColor'], required_imports)
-
2
colors_params << "trackColor = #{trackcolor_resolved}"
-
end
-
-
3
if colors_params.any?
-
7
code += ",\n" + colors_params.map { |param| indent(param, depth + 1) }.join(",\n")
-
end
-
end
-
-
15
code += "\n" + indent(")", depth)
-
15
code
-
end
-
-
1
private
-
-
1
def self.indent(text, level)
-
41
return text if level == 0
-
10
spaces = ' ' * level
-
10
text.split("\n").map { |line|
-
13
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class RadioComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
# Handle Radio group with items FIRST (higher priority)
-
15
if json_data['items']
-
3
return generate_radio_group_with_items(json_data, depth, required_imports, parent_type)
-
end
-
-
# Handle individual Radio item (not a group)
-
12
if json_data['group'] || json_data['text']
-
6
return generate_radio_item(json_data, depth, required_imports, parent_type)
-
end
-
# Radio uses 'bind' for selected value
-
6
selected = if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
4
variable = $1
-
4
"data.#{variable}"
-
else
-
2
'""'
-
end
-
-
6
code = indent("Column(", depth)
-
-
# Build modifiers
-
6
modifiers = []
-
6
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
6
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
6
code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
-
6
code += "\n" + indent(") {", depth)
-
-
# Radio options
-
6
if json_data['options']
-
5
if json_data['options'].is_a?(Array)
-
4
json_data['options'].each do |option|
-
9
option_value = option.is_a?(Hash) ? option['value'] : option
-
9
option_label = option.is_a?(Hash) ? option['label'] : option
-
-
9
code += "\n" + indent("Row(", depth + 1)
-
9
code += "\n" + indent("verticalAlignment = Alignment.CenterVertically,", depth + 2)
-
9
code += "\n" + indent("modifier = Modifier", depth + 2)
-
9
code += "\n" + indent(" .fillMaxWidth()", depth + 2)
-
9
code += "\n" + indent(" .clickable {", depth + 2)
-
-
9
if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
7
variable = $1
-
7
code += "\n" + indent(" viewModel.updateData(mapOf(\"#{variable}\" to \"#{option_value}\"))", depth + 2)
-
2
elsif json_data['onValueChange']
-
# onValueChange (camelCase) -> binding format only (@{functionName})
-
2
if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
-
2
method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
-
2
code += "\n" + indent(" viewModel.#{method_name}(\"#{option_value}\")", depth + 2)
-
else
-
code += "\n" + indent(" // ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName}", depth + 2)
-
end
-
end
-
-
9
code += "\n" + indent(" }", depth + 2)
-
9
code += "\n" + indent(") {", depth + 1)
-
-
# RadioButton
-
9
code += "\n" + indent("RadioButton(", depth + 2)
-
9
code += "\n" + indent("selected = (#{selected} == \"#{option_value}\"),", depth + 3)
-
9
code += "\n" + indent("onClick = {", depth + 3)
-
-
9
if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
7
variable = $1
-
7
code += "\n" + indent("viewModel.updateData(mapOf(\"#{variable}\" to \"#{option_value}\"))", depth + 4)
-
2
elsif json_data['onValueChange']
-
# onValueChange (camelCase) -> binding format only (@{functionName})
-
2
if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
-
2
method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
-
2
code += "\n" + indent("viewModel.#{method_name}(\"#{option_value}\")", depth + 4)
-
else
-
code += "\n" + indent("// ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName}", depth + 4)
-
end
-
end
-
-
9
code += "\n" + indent("}", depth + 3)
-
-
# RadioButton colors
-
9
if json_data['selectedColor'] || json_data['unselectedColor']
-
2
required_imports&.add(:radio_colors)
-
2
colors_params = []
-
-
2
if json_data['selectedColor']
-
2
selectedcolor_resolved = Helpers::ResourceResolver.process_color(json_data['selectedColor'], required_imports)
-
2
colors_params << "selectedColor = #{selectedcolor_resolved}"
-
end
-
-
2
if json_data['unselectedColor']
-
2
unselectedcolor_resolved = Helpers::ResourceResolver.process_color(json_data['unselectedColor'], required_imports)
-
2
colors_params << "unselectedColor = #{unselectedcolor_resolved}"
-
end
-
-
2
if colors_params.any?
-
2
code += ",\n" + indent("colors = RadioButtonDefaults.colors(", depth + 3)
-
6
code += "\n" + colors_params.map { |param| indent(param, depth + 4) }.join(",\n")
-
2
code += "\n" + indent(")", depth + 3)
-
end
-
end
-
-
9
code += "\n" + indent(")", depth + 2)
-
-
# Label text
-
9
code += "\n" + indent("Spacer(modifier = Modifier.width(8.dp))", depth + 2)
-
9
code += "\n" + indent("Text(\"#{option_label}\")", depth + 2)
-
-
9
code += "\n" + indent("}", depth + 1)
-
end
-
1
elsif json_data['options'].is_a?(String) && json_data['options'].match(/@\{([^}]+)\}/)
-
# Dynamic options from data binding
-
1
options_var = $1
-
1
code += "\n" + indent("data.#{options_var}.forEach { option ->", depth + 1)
-
1
code += "\n" + indent("Row(", depth + 2)
-
1
code += "\n" + indent("verticalAlignment = Alignment.CenterVertically,", depth + 3)
-
1
code += "\n" + indent("modifier = Modifier.fillMaxWidth().clickable {", depth + 3)
-
-
1
if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
1
variable = $1
-
1
code += "\n" + indent("viewModel.updateData(mapOf(\"#{variable}\" to option))", depth + 4)
-
end
-
-
1
code += "\n" + indent("}", depth + 3)
-
1
code += "\n" + indent(") {", depth + 2)
-
1
code += "\n" + indent("RadioButton(", depth + 3)
-
1
code += "\n" + indent("selected = (#{selected} == option),", depth + 4)
-
1
code += "\n" + indent("onClick = {", depth + 4)
-
-
1
if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
1
variable = $1
-
1
code += "\n" + indent("viewModel.updateData(mapOf(\"#{variable}\" to option))", depth + 5)
-
end
-
-
1
code += "\n" + indent("}", depth + 4)
-
1
code += "\n" + indent(")", depth + 3)
-
1
code += "\n" + indent("Spacer(modifier = Modifier.width(8.dp))", depth + 3)
-
1
code += "\n" + indent("Text(option)", depth + 3)
-
1
code += "\n" + indent("}", depth + 2)
-
1
code += "\n" + indent("}", depth + 1)
-
end
-
end
-
-
6
code += "\n" + indent("}", depth)
-
6
code
-
end
-
-
1
private
-
-
1
def self.generate_radio_item(json_data, depth, required_imports, parent_type)
-
6
group = json_data['group'] || 'default'
-
6
id = json_data['id'] || "radio_#{rand(1000)}"
-
6
text = json_data['text'] || ''
-
-
# Get the selected state from binding
-
6
selected_var = "selectedRadiogroup" # Default variable name
-
6
if group.downcase != 'default'
-
# Use group name as part of the variable
-
1
selected_var = "selected#{group.capitalize}"
-
end
-
-
6
code = indent("Row(", depth)
-
6
code += "\n" + indent(" verticalAlignment = Alignment.CenterVertically,", depth)
-
-
# Build modifiers
-
6
modifiers = []
-
6
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
6
if modifiers.any?
-
code += "\n" + indent(" modifier = Modifier", depth)
-
modifiers.each do |mod|
-
code += "\n" + indent(" #{mod}", depth)
-
end
-
end
-
-
6
code += "\n" + indent(") {", depth)
-
-
# Handle custom icons or default components
-
# If icon is "circle" or selectedIcon is "checkmark.circle.fill", use default RadioButton
-
6
if (json_data['icon'] == 'circle' || !json_data['icon']) &&
-
(json_data['selectedIcon'] == 'checkmark.circle.fill' || !json_data['selectedIcon'])
-
# Use default RadioButton for standard radio appearance
-
3
code += "\n" + indent(" RadioButton(", depth)
-
3
code += "\n" + indent(" selected = data.#{selected_var} == \"#{id}\",", depth)
-
3
code += "\n" + indent(" onClick = { viewModel.updateData(mapOf(\"#{selected_var}\" to \"#{id}\")) }", depth)
-
3
code += "\n" + indent(" )", depth)
-
3
elsif json_data['icon'] == 'square' &&
-
(json_data['selectedIcon'] == 'checkmark.square.fill' || !json_data['selectedIcon'])
-
# Use default Checkbox for square appearance
-
1
required_imports&.add(:checkbox)
-
1
code += "\n" + indent(" Checkbox(", depth)
-
1
code += "\n" + indent(" checked = data.#{selected_var} == \"#{id}\",", depth)
-
1
code += "\n" + indent(" onCheckedChange = { viewModel.updateData(mapOf(\"#{selected_var}\" to \"#{id}\")) }", depth)
-
1
code += "\n" + indent(" )", depth)
-
2
elsif json_data['icon'] || json_data['selectedIcon']
-
# Use IconButton with custom icons only for non-standard icons
-
2
required_imports&.add(:icon_button)
-
2
required_imports&.add(:icons)
-
-
2
icon = map_icon_name(json_data['icon'] || 'star')
-
2
selected_icon = map_icon_name(json_data['selectedIcon'] || 'star.fill')
-
-
2
code += "\n" + indent(" val isSelected = data.#{selected_var} == \"#{id}\"", depth)
-
2
code += "\n" + indent(" IconButton(", depth)
-
2
code += "\n" + indent(" onClick = { viewModel.updateData(mapOf(\"#{selected_var}\" to \"#{id}\")) }", depth)
-
2
code += "\n" + indent(" ) {", depth)
-
2
code += "\n" + indent(" Icon(", depth)
-
2
code += "\n" + indent(" imageVector = if (isSelected) #{selected_icon} else #{icon},", depth)
-
2
code += "\n" + indent(" contentDescription = \"#{text}\",", depth)
-
-
2
if json_data['selectedColor'] || json_data['tintColor']
-
1
color = json_data['selectedColor'] || json_data['tintColor']
-
1
selected_color = Helpers::ResourceResolver.process_color(color, required_imports)
-
1
code += "\n" + indent(" tint = if (isSelected) #{selected_color} else Color.Gray", depth)
-
else
-
1
code += "\n" + indent(" tint = if (isSelected) MaterialTheme.colorScheme.primary else Color.Gray", depth)
-
end
-
-
2
code += "\n" + indent(" )", depth)
-
2
code += "\n" + indent(" }", depth)
-
else
-
# Default RadioButton
-
code += "\n" + indent(" RadioButton(", depth)
-
code += "\n" + indent(" selected = data.#{selected_var} == \"#{id}\",", depth)
-
code += "\n" + indent(" onClick = { viewModel.updateData(mapOf(\"#{selected_var}\" to \"#{id}\")) }", depth)
-
code += "\n" + indent(" )", depth)
-
end
-
-
# Add text label
-
6
if text && !text.empty?
-
6
code += "\n" + indent(" Spacer(modifier = Modifier.width(8.dp))", depth)
-
# Add text with color
-
6
if json_data['fontColor'] || json_data['textColor']
-
1
text_color = json_data['fontColor'] || json_data['textColor']
-
1
color_resolved = Helpers::ResourceResolver.process_color(text_color, required_imports)
-
1
code += "\n" + indent(" Text(\"#{text}\", color = #{color_resolved})", depth)
-
else
-
# Default to black color
-
5
code += "\n" + indent(" Text(\"#{text}\", color = Color.Black)", depth)
-
end
-
end
-
-
6
code += "\n" + indent("}", depth)
-
6
code
-
end
-
-
1
def self.generate_radio_group_with_items(json_data, depth, required_imports, parent_type)
-
3
items = json_data['items']
-
3
selected_value = json_data['selectedValue']
-
-
# Add required import for clickable
-
3
required_imports&.add(:clickable)
-
-
# Extract binding variable
-
3
selected_var = if selected_value && selected_value.match(/@\{([^}]+)\}/)
-
3
"data.#{$1}"
-
else
-
'""'
-
end
-
-
3
code = indent("Column(", depth)
-
-
# Build modifiers
-
3
modifiers = []
-
3
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
3
if modifiers.any?
-
code += "\n" + indent(" modifier = Modifier", depth)
-
modifiers.each do |mod|
-
code += "\n" + indent(" #{mod}", depth)
-
end
-
end
-
-
3
code += "\n" + indent(") {", depth)
-
-
# Add label if present
-
3
if json_data['text']
-
1
if json_data['fontColor'] || json_data['textColor']
-
text_color = json_data['fontColor'] || json_data['textColor']
-
color_resolved = Helpers::ResourceResolver.process_color(text_color, required_imports)
-
code += "\n" + indent(" Text(\"#{json_data['text']}\", color = #{color_resolved})", depth)
-
else
-
# Default to black color
-
1
code += "\n" + indent(" Text(\"#{json_data['text']}\", color = Color.Black)", depth)
-
end
-
1
code += "\n" + indent(" Spacer(modifier = Modifier.height(8.dp))", depth)
-
end
-
-
# Generate radio items
-
3
items.each do |item|
-
7
code += "\n" + indent(" Row(", depth)
-
7
code += "\n" + indent(" verticalAlignment = Alignment.CenterVertically,", depth)
-
7
code += "\n" + indent(" modifier = Modifier", depth)
-
7
code += "\n" + indent(" .fillMaxWidth()", depth)
-
7
code += "\n" + indent(" .clickable {", depth)
-
-
7
if selected_value && selected_value.match(/@\{([^}]+)\}/)
-
7
variable = $1
-
7
code += "\n" + indent(" viewModel.updateData(mapOf(\"#{variable}\" to \"#{item}\"))", depth)
-
end
-
-
7
code += "\n" + indent(" }", depth)
-
7
code += "\n" + indent(" ) {", depth)
-
7
code += "\n" + indent(" RadioButton(", depth)
-
7
code += "\n" + indent(" selected = #{selected_var} == \"#{item}\",", depth)
-
7
code += "\n" + indent(" onClick = {", depth)
-
-
7
if selected_value && selected_value.match(/@\{([^}]+)\}/)
-
7
variable = $1
-
7
code += "\n" + indent(" viewModel.updateData(mapOf(\"#{variable}\" to \"#{item}\"))", depth)
-
end
-
-
7
code += "\n" + indent(" }", depth)
-
7
code += "\n" + indent(" )", depth)
-
7
code += "\n" + indent(" Spacer(modifier = Modifier.width(8.dp))", depth)
-
# Add text with black color
-
7
if json_data['fontColor'] || json_data['textColor']
-
2
text_color = json_data['fontColor'] || json_data['textColor']
-
2
color_resolved = Helpers::ResourceResolver.process_color(text_color, required_imports)
-
2
code += "\n" + indent(" Text(\"#{item}\", color = #{color_resolved})", depth)
-
else
-
# Default to black color
-
5
code += "\n" + indent(" Text(\"#{item}\", color = Color.Black)", depth)
-
end
-
7
code += "\n" + indent(" }", depth)
-
end
-
-
3
code += "\n" + indent("}", depth)
-
3
code
-
end
-
-
1
def self.map_icon_name(icon_name)
-
# Map iOS SF Symbols to Material Icons
-
9
icon_map = {
-
'circle' => 'Icons.Outlined.PanoramaFishEye', # Using PanoramaFishEye as it's a hollow circle
-
'checkmark.circle.fill' => 'Icons.Filled.CheckCircle',
-
'star' => 'Icons.Outlined.Star',
-
'star.fill' => 'Icons.Filled.Star',
-
'heart' => 'Icons.Outlined.FavoriteBorder',
-
'heart.fill' => 'Icons.Filled.Favorite',
-
'square' => 'Icons.Outlined.CheckBoxOutlineBlank',
-
'checkmark.square.fill' => 'Icons.Default.CheckBox' # Use Default.CheckBox instead of Filled.CheckBox
-
}
-
-
9
icon_map[icon_name] || 'Icons.Outlined.Star' # Default fallback to star
-
end
-
-
1
def self.indent(text, level)
-
400
return text if level == 0
-
179
spaces = ' ' * level
-
179
text.split("\n").map { |line|
-
179
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class ScrollViewComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
# スクロール方向の判定
-
# horizontalScroll属性、orientation属性、またはchild要素の配置から判定
-
13
is_horizontal = false
-
-
# 1. horizontalScroll属性を最優先
-
13
if json_data.key?('horizontalScroll')
-
2
is_horizontal = json_data['horizontalScroll']
-
# 2. orientation属性を次に確認
-
11
elsif json_data.key?('orientation')
-
1
is_horizontal = json_data['orientation'] == 'horizontal'
-
# 3. child要素の配置から判定
-
10
elsif json_data['child']
-
3
children = json_data['child']
-
# childを配列として扱う
-
3
children = [children] unless children.is_a?(Array)
-
-
# 配列の中から最初のViewコンポーネントを探す
-
6
first_view = children.find { |child| child.is_a?(Hash) && child['type'] == 'View' }
-
3
if first_view
-
1
is_horizontal = first_view['orientation'] == 'horizontal'
-
end
-
end
-
-
# keyboardAvoidance属性の確認(デフォルトはtrue)
-
13
keyboard_avoidance = json_data['keyboardAvoidance'] != false
-
-
13
if is_horizontal
-
4
required_imports&.add(:lazy_row)
-
4
code = indent("LazyRow(", depth)
-
else
-
9
required_imports&.add(:lazy_column)
-
9
code = indent("LazyColumn(", depth)
-
end
-
-
# Build modifiers
-
13
modifiers = []
-
-
13
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
13
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
13
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
13
modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
-
-
# Apply keyboard avoidance at the end of modifier chain
-
13
if keyboard_avoidance
-
11
required_imports&.add(:ime_padding)
-
11
modifiers << ".imePadding()"
-
end
-
-
13
code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
-
-
13
code += "\n" + indent(") {", depth)
-
13
code += "\n" + indent("item {", depth + 1)
-
-
# Process children
-
13
children = json_data['child'] || []
-
13
children = [children] unless children.is_a?(Array)
-
-
# Return structure for parent to process children
-
{
-
13
code: code,
-
children: children,
-
closing: "\n" + indent("}", depth + 1) + "\n" + indent("}", depth),
-
json_data: json_data
-
}
-
end
-
-
1
private
-
-
1
def self.indent(text, level)
-
65
return text if level == 0
-
26
spaces = ' ' * level
-
26
text.split("\n").map { |line|
-
26
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class SegmentComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
22
required_imports&.add(:segment)
-
-
# Segment uses 'selectedIndex' or 'bind' for selected index
-
# Track if the selected index is dynamic (from data binding) or static
-
22
is_dynamic_index = false
-
22
selected_index = if json_data['selectedIndex']
-
5
if json_data['selectedIndex'].is_a?(String) && json_data['selectedIndex'].match(/@\{([^}]+)\}/)
-
3
variable = $1
-
3
is_dynamic_index = true
-
3
"data.#{variable}"
-
else
-
# Direct integer value - keep as integer for proper comparison
-
2
json_data['selectedIndex'].to_i
-
end
-
17
elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
1
variable = $1
-
1
is_dynamic_index = true
-
1
"data.#{variable}"
-
else
-
16
0 # Default to 0 as integer
-
end
-
-
# Support both 'items' and 'segments' attribute names
-
22
segments = json_data['items'] || json_data['segments'] || []
-
-
22
code = indent("Segment(", depth)
-
# For display in Segment parameter, always output as string
-
22
selected_tab_param = is_dynamic_index ? selected_index : selected_index.to_s
-
22
code += "\n" + indent("selectedTabIndex = #{selected_tab_param},", depth + 1)
-
-
# Add enabled state if specified
-
22
if json_data.key?('enabled')
-
2
enabled_value = json_data['enabled']
-
2
if enabled_value.is_a?(String) && enabled_value.match(/@\{([^}]+)\}/)
-
1
code += "\n" + indent("enabled = data.#{$1},", depth + 1)
-
else
-
1
code += "\n" + indent("enabled = #{enabled_value},", depth + 1)
-
end
-
end
-
-
# Tab colors - only add if specified, otherwise use defaults from Configuration
-
22
colors_params = []
-
-
# Background color (containerColor)
-
22
if json_data['backgroundColor']
-
1
bg_color = Helpers::ResourceResolver.process_color(json_data['backgroundColor'], required_imports)
-
1
colors_params << "containerColor = #{bg_color}"
-
end
-
-
# Normal text color (contentColor) - for unselected tabs
-
22
if json_data['normalColor']
-
3
normal_color = Helpers::ResourceResolver.process_color(json_data['normalColor'], required_imports)
-
3
colors_params << "contentColor = #{normal_color}"
-
end
-
-
# Selected text color (selectedContentColor)
-
22
if json_data['selectedColor'] || json_data['tintColor'] || json_data['selectedSegmentTintColor']
-
4
color = json_data['selectedColor'] || json_data['tintColor'] || json_data['selectedSegmentTintColor']
-
4
selected_color = Helpers::ResourceResolver.process_color(color, required_imports)
-
4
colors_params << "selectedContentColor = #{selected_color}"
-
end
-
-
# Indicator color - only if specified
-
22
if json_data['indicatorColor']
-
1
indicator_color = Helpers::ResourceResolver.process_color(json_data['indicatorColor'], required_imports)
-
1
colors_params << "indicatorColor = #{indicator_color}"
-
end
-
-
22
if colors_params.any?
-
7
code += "\n" + indent(colors_params.join(",\n"), depth + 1) + ","
-
end
-
-
# Build modifiers
-
22
modifiers = []
-
22
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
22
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
22
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
22
code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
-
-
22
code += "\n" + indent(") {", depth)
-
-
# Generate tabs
-
22
if segments.is_a?(Array)
-
21
segments.each_with_index do |segment, index|
-
41
code += "\n" + indent("Tab(", depth + 1)
-
# For selected comparison, handle both dynamic and static cases
-
41
selected_comparison = is_dynamic_index ? "(#{selected_index} == #{index})" : (selected_index == index).to_s
-
41
code += "\n" + indent("selected = #{selected_comparison},", depth + 2)
-
-
# Add enabled state to Tab if segment is disabled
-
41
if json_data.key?('enabled')
-
4
enabled_value = json_data['enabled']
-
4
if enabled_value.is_a?(String) && enabled_value.match(/@\{([^}]+)\}/)
-
2
code += "\n" + indent("enabled = data.#{$1},", depth + 2)
-
else
-
2
code += "\n" + indent("enabled = #{enabled_value},", depth + 2)
-
end
-
end
-
-
41
code += "\n" + indent("onClick = {", depth + 2)
-
-
# Check if we have a binding variable
-
41
has_binding = false
-
41
binding_variable = nil
-
-
41
if json_data['selectedIndex'] && json_data['selectedIndex'].is_a?(String) && json_data['selectedIndex'].match(/@\{([^}]+)\}/)
-
6
has_binding = true
-
6
binding_variable = $1
-
35
elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
2
has_binding = true
-
2
binding_variable = $1
-
end
-
-
# Generate onClick handler
-
# onValueChange (camelCase) -> binding format only (@{functionName})
-
41
if json_data['onValueChange']
-
2
if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
-
2
method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
-
2
code += "\n" + indent("viewModel.#{method_name}(#{index})", depth + 3)
-
else
-
code += "\n" + indent("// ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName}", depth + 3)
-
end
-
39
elsif has_binding
-
# Update the bound variable
-
8
code += "\n" + indent("viewModel.updateData(mapOf(\"#{binding_variable}\" to #{index}))", depth + 3)
-
else
-
# No action if selectedIndex is a static value with no binding
-
31
code += "\n" + indent("// Static selected index", depth + 3)
-
end
-
-
41
code += "\n" + indent("},", depth + 2)
-
-
# Generate text with color based on selection
-
# Store color info for later use
-
41
normal_color = json_data['normalColor']
-
41
selected_color = json_data['selectedColor'] || json_data['tintColor'] || json_data['selectedSegmentTintColor']
-
-
41
if normal_color || selected_color
-
# Need to handle text color based on selection
-
10
code += "\n" + indent("text = {", depth + 2)
-
10
code += "\n" + indent("Text(", depth + 3)
-
10
code += "\n" + indent("\"#{segment}\",", depth + 4)
-
-
# Use conditional color based on selection
-
10
if is_dynamic_index
-
2
if selected_color && normal_color
-
2
selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
-
2
normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
-
2
code += "\n" + indent("color = if (#{selected_index} == #{index}) #{selected_resolved} else #{normal_resolved}", depth + 4)
-
elsif selected_color
-
selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
-
code += "\n" + indent("color = if (#{selected_index} == #{index}) #{selected_resolved} else Color.Unspecified", depth + 4)
-
elsif normal_color
-
normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
-
code += "\n" + indent("color = if (#{selected_index} == #{index}) Color.Unspecified else #{normal_resolved}", depth + 4)
-
end
-
else
-
# Static index
-
8
is_selected = (selected_index == index)
-
8
if is_selected && selected_color
-
3
selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
-
3
code += "\n" + indent("color = #{selected_resolved}", depth + 4)
-
5
elsif !is_selected && normal_color
-
2
normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
-
2
code += "\n" + indent("color = #{normal_resolved}", depth + 4)
-
end
-
end
-
-
10
code += "\n" + indent(")", depth + 3)
-
10
code += "\n" + indent("}", depth + 2)
-
else
-
31
code += "\n" + indent("text = { Text(\"#{segment}\") }", depth + 2)
-
end
-
-
41
code += "\n" + indent(")", depth + 1)
-
end
-
1
elsif segments.is_a?(String) && segments.match(/@\{([^}]+)\}/)
-
# Dynamic segments from data binding
-
1
segments_var = $1
-
1
code += "\n" + indent("data.#{segments_var}.forEachIndexed { index, segment ->", depth + 1)
-
1
code += "\n" + indent("Tab(", depth + 2)
-
# For dynamic segments, selected_index comparison depends on whether the index itself is dynamic
-
1
selected_comparison = is_dynamic_index ? "(#{selected_index} == index)" : "(#{selected_index} == index)"
-
1
code += "\n" + indent("selected = #{selected_comparison},", depth + 3)
-
-
# Add enabled state to Tab if segment is disabled
-
1
if json_data.key?('enabled')
-
enabled_value = json_data['enabled']
-
if enabled_value.is_a?(String) && enabled_value.match(/@\{([^}]+)\}/)
-
code += "\n" + indent("enabled = data.#{$1},", depth + 3)
-
else
-
code += "\n" + indent("enabled = #{enabled_value},", depth + 3)
-
end
-
end
-
-
1
code += "\n" + indent("onClick = {", depth + 3)
-
-
# Check if we have a binding variable
-
1
has_binding = false
-
1
binding_variable = nil
-
-
1
if json_data['selectedIndex'] && json_data['selectedIndex'].is_a?(String) && json_data['selectedIndex'].match(/@\{([^}]+)\}/)
-
has_binding = true
-
binding_variable = $1
-
1
elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
has_binding = true
-
binding_variable = $1
-
end
-
-
# Generate onClick handler
-
# onValueChange (camelCase) -> binding format only (@{functionName})
-
1
if json_data['onValueChange']
-
if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
-
method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
-
code += "\n" + indent("viewModel.#{method_name}(index)", depth + 4)
-
else
-
code += "\n" + indent("// ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName}", depth + 4)
-
end
-
1
elsif has_binding
-
# Update the bound variable
-
code += "\n" + indent("viewModel.updateData(mapOf(\"#{binding_variable}\" to index))", depth + 4)
-
else
-
# No action if selectedIndex is a static value with no binding
-
1
code += "\n" + indent("// Static selected index", depth + 4)
-
end
-
-
1
code += "\n" + indent("},", depth + 3)
-
-
# Generate text with color based on selection for dynamic segments
-
1
normal_color = json_data['normalColor']
-
1
selected_color = json_data['selectedColor'] || json_data['tintColor'] || json_data['selectedSegmentTintColor']
-
-
1
if normal_color || selected_color
-
code += "\n" + indent("text = {", depth + 3)
-
code += "\n" + indent("Text(", depth + 4)
-
code += "\n" + indent("segment,", depth + 5)
-
-
# Use conditional color based on selection
-
if selected_color && normal_color
-
selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
-
normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
-
code += "\n" + indent("color = if (#{selected_comparison}) #{selected_resolved} else #{normal_resolved}", depth + 5)
-
elsif selected_color
-
selected_resolved = Helpers::ResourceResolver.process_color(selected_color, required_imports)
-
code += "\n" + indent("color = if (#{selected_comparison}) #{selected_resolved} else Color.Unspecified", depth + 5)
-
elsif normal_color
-
normal_resolved = Helpers::ResourceResolver.process_color(normal_color, required_imports)
-
code += "\n" + indent("color = if (#{selected_comparison}) Color.Unspecified else #{normal_resolved}", depth + 5)
-
end
-
-
code += "\n" + indent(")", depth + 4)
-
code += "\n" + indent("}", depth + 3)
-
else
-
1
code += "\n" + indent("text = { Text(segment) }", depth + 3)
-
end
-
-
1
code += "\n" + indent(")", depth + 2)
-
1
code += "\n" + indent("}", depth + 1)
-
end
-
-
22
code += "\n" + indent("}", depth)
-
22
code
-
end
-
-
1
private
-
-
1
def self.indent(text, level)
-
446
return text if level == 0
-
379
spaces = ' ' * level
-
379
text.split("\n").map { |line|
-
381
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class SelectBoxComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
28
required_imports&.add(:selectbox_component)
-
-
# Check if this is a date picker
-
28
is_date_picker = json_data['selectItemType'] == 'Date'
-
-
# SelectBox uses 'selectedItem', 'selectedDate', or 'bind' for selected value
-
# For date pickers, selectedDate takes priority
-
28
selected = if is_date_picker && json_data['selectedDate'] && json_data['selectedDate'].match(/@\{([^}]+)\}/)
-
variable = $1
-
"data.#{variable}"
-
28
elsif json_data['selectedItem'] && json_data['selectedItem'].match(/@\{([^}]+)\}/)
-
1
variable = $1
-
1
"data.#{variable}"
-
27
elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
1
variable = $1
-
1
"data.#{variable}"
-
else
-
26
'""'
-
end
-
-
# Use DateSelectBox for date type
-
28
if is_date_picker
-
9
required_imports&.add(:date_selectbox_component)
-
9
code = indent("DateSelectBox(", depth)
-
else
-
19
code = indent("SelectBox(", depth)
-
end
-
28
code += "\n" + indent("value = #{selected},", depth + 1)
-
-
# Handle onValueChange callback
-
# For date pickers, check selectedDate first
-
28
binding_variable = nil
-
28
if is_date_picker && json_data['selectedDate'] && json_data['selectedDate'].match(/@\{([^}]+)\}/)
-
binding_variable = $1
-
28
elsif json_data['selectedItem'] && json_data['selectedItem'].match(/@\{([^}]+)\}/)
-
1
binding_variable = $1
-
27
elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
1
binding_variable = $1
-
end
-
-
28
if binding_variable
-
2
code += "\n" + indent("onValueChange = { newValue ->", depth + 1)
-
2
code += "\n" + indent("viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue))", depth + 2)
-
2
code += "\n" + indent("},", depth + 1)
-
else
-
26
code += "\n" + indent("onValueChange = { },", depth + 1)
-
end
-
-
# For date picker, add date-specific parameters
-
28
if is_date_picker
-
# Date picker mode (date, time, dateAndTime)
-
9
if json_data['datePickerMode']
-
1
code += "\n" + indent("datePickerMode = \"#{json_data['datePickerMode']}\",", depth + 1)
-
end
-
-
# Date picker style
-
9
if json_data['datePickerStyle']
-
1
code += "\n" + indent("datePickerStyle = \"#{json_data['datePickerStyle']}\",", depth + 1)
-
end
-
-
# Date format (or dateStringFormat)
-
9
date_format = json_data['dateFormat'] || json_data['dateStringFormat']
-
9
if date_format
-
2
code += "\n" + indent("dateFormat = \"#{date_format}\",", depth + 1)
-
end
-
-
# Minute interval for time pickers
-
9
if json_data['minuteInterval']
-
1
code += "\n" + indent("minuteInterval = #{json_data['minuteInterval']},", depth + 1)
-
end
-
-
# Minimum date
-
9
if json_data['minimumDate']
-
1
code += "\n" + indent("minimumDate = \"#{json_data['minimumDate']}\",", depth + 1)
-
end
-
-
# Maximum date
-
9
if json_data['maximumDate']
-
1
code += "\n" + indent("maximumDate = \"#{json_data['maximumDate']}\",", depth + 1)
-
end
-
else
-
# Options (use 'items' or 'options') - only for non-date SelectBox
-
19
options_data = json_data['items'] || json_data['options']
-
19
if options_data
-
6
if options_data.is_a?(String) && options_data.match(/@\{([^}]+)\}/)
-
# Dynamic options from data binding
-
1
options_var = $1
-
1
code += "\n" + indent("options = data.#{options_var},", depth + 1)
-
5
elsif options_data.is_a?(Array)
-
# Static options array
-
5
options_list = options_data.map do |option|
-
12
if option.is_a?(Hash)
-
2
"\"#{option['label'] || option['value']}\""
-
else
-
10
"\"#{option}\""
-
end
-
end.join(", ")
-
5
code += "\n" + indent("options = listOf(#{options_list}),", depth + 1)
-
else
-
code += "\n" + indent("options = emptyList(),", depth + 1)
-
end
-
else
-
13
code += "\n" + indent("options = emptyList(),", depth + 1)
-
end
-
end
-
-
# Add placeholder/hint if specified
-
28
if json_data['hint']
-
1
code += "\n" + indent("placeholder = \"#{json_data['hint']}\",", depth + 1)
-
27
elsif json_data['placeholder']
-
1
code += "\n" + indent("placeholder = \"#{json_data['placeholder']}\",", depth + 1)
-
end
-
-
# Add enabled state if specified
-
28
if json_data['disabled']
-
1
code += "\n" + indent("enabled = false,", depth + 1)
-
27
elsif json_data['enabled'] == false
-
1
code += "\n" + indent("enabled = false,", depth + 1)
-
end
-
-
# Add style parameters
-
28
if json_data['background']
-
1
bg_color = Helpers::ResourceResolver.process_color(json_data['background'], required_imports)
-
1
code += "\n" + indent("backgroundColor = #{bg_color},", depth + 1)
-
end
-
-
28
if json_data['borderColor']
-
1
border_color = Helpers::ResourceResolver.process_color(json_data['borderColor'], required_imports)
-
1
code += "\n" + indent("borderColor = #{border_color},", depth + 1)
-
end
-
-
28
if json_data['fontColor']
-
1
text_color = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
-
1
code += "\n" + indent("textColor = #{text_color},", depth + 1)
-
end
-
-
28
if json_data['hintColor']
-
1
hint_color = Helpers::ResourceResolver.process_color(json_data['hintColor'], required_imports)
-
1
code += "\n" + indent("hintColor = #{hint_color},", depth + 1)
-
end
-
-
28
if json_data['cornerRadius']
-
1
code += "\n" + indent("cornerRadius = #{json_data['cornerRadius']},", depth + 1)
-
end
-
-
# Font styling
-
28
if json_data['fontSize']
-
code += "\n" + indent("fontSize = #{json_data['fontSize']},", depth + 1)
-
end
-
-
28
if json_data['font']
-
font_weight = case json_data['font'].to_s.downcase
-
when 'bold'
-
'FontWeight.Bold'
-
when 'semibold'
-
'FontWeight.SemiBold'
-
when 'medium'
-
'FontWeight.Medium'
-
when 'light'
-
'FontWeight.Light'
-
when 'thin'
-
'FontWeight.Thin'
-
else
-
'FontWeight.Normal'
-
end
-
code += "\n" + indent("fontWeight = #{font_weight},", depth + 1)
-
end
-
-
# Add cancel button background color if specified
-
28
if json_data['cancelButtonBackgroundColor']
-
1
cancel_bg = Helpers::ResourceResolver.process_color(json_data['cancelButtonBackgroundColor'], required_imports)
-
1
code += "\n" + indent("cancelButtonBackgroundColor = #{cancel_bg},", depth + 1)
-
end
-
-
# Add cancel button text color if specified
-
28
if json_data['cancelButtonTextColor']
-
1
cancel_text = Helpers::ResourceResolver.process_color(json_data['cancelButtonTextColor'], required_imports)
-
1
code += "\n" + indent("cancelButtonTextColor = #{cancel_text},", depth + 1)
-
end
-
-
# Build modifiers
-
28
modifiers = []
-
-
# Ensure fillMaxWidth if width is not specified for date pickers
-
28
if is_date_picker && !json_data['width']
-
9
modifiers << ".fillMaxWidth()"
-
end
-
-
28
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
28
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
28
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
28
modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
-
-
28
if modifiers.any? && !modifiers.include?('SKIP_RENDER')
-
9
code += Helpers::ModifierBuilder.format(modifiers, depth)
-
end
-
-
28
code += "\n" + indent(")", depth)
-
28
code
-
end
-
-
1
private
-
-
1
def self.indent(text, level)
-
155
return text if level == 0
-
98
spaces = ' ' * level
-
98
text.split("\n").map { |line|
-
98
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class SliderComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
# Slider uses 'value' or 'bind' for binding
-
23
value = if json_data['value']
-
2
if json_data['value'].is_a?(String) && json_data['value'].match(/@\{([^}]+)\}/)
-
1
variable = $1
-
1
"data.#{variable}.toFloat()"
-
else
-
# Direct value
-
1
"#{json_data['value']}f"
-
end
-
21
elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
1
variable = $1
-
1
"data.#{variable}.toFloat()"
-
else
-
20
'0f'
-
end
-
-
# Support both naming conventions for min/max
-
23
min_value = json_data['minimumValue'] || json_data['min'] || 0
-
23
max_value = json_data['maximumValue'] || json_data['max'] || 100
-
-
23
code = indent("Slider(", depth)
-
23
code += "\n" + indent("value = #{value},", depth + 1)
-
-
# onValueChange handler
-
23
binding_variable = nil
-
23
if json_data['value'] && json_data['value'].is_a?(String) && json_data['value'].match(/@\{([^}]+)\}/)
-
1
binding_variable = $1
-
22
elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
1
binding_variable = $1
-
end
-
-
23
if json_data['onValueChange']
-
# onValueChange (camelCase) -> binding format only (@{functionName})
-
1
if Helpers::ModifierBuilder.is_binding?(json_data['onValueChange'])
-
1
method_name = Helpers::ModifierBuilder.extract_binding_property(json_data['onValueChange'])
-
1
code += "\n" + indent("onValueChange = { viewModel.#{method_name}(it) },", depth + 1)
-
else
-
code += "\n" + indent("onValueChange = { // ERROR: #{json_data['onValueChange']} - camelCase events require binding format @{functionName} },", depth + 1)
-
end
-
22
elsif binding_variable
-
# Update the bound variable - check if it's Int or Double/Float based on the data type
-
2
code += "\n" + indent("onValueChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue.toDouble())) },", depth + 1)
-
else
-
20
code += "\n" + indent("onValueChange = { },", depth + 1)
-
end
-
-
# Value range
-
23
code += "\n" + indent("valueRange = #{min_value}f..#{max_value}f,", depth + 1)
-
-
# Steps
-
23
if json_data['step'] && json_data['step'] > 0
-
1
steps = ((max_value - min_value) / json_data['step'].to_f).to_i - 1
-
1
code += "\n" + indent("steps = #{steps},", depth + 1) if steps > 0
-
end
-
-
# Build modifiers
-
23
modifiers = []
-
23
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
23
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
23
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
23
code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
-
-
# Slider colors
-
23
if json_data['minimumTrackTintColor'] || json_data['maximumTrackTintColor'] || json_data['thumbTintColor']
-
4
required_imports&.add(:slider_colors)
-
4
colors_params = []
-
-
4
if json_data['thumbTintColor']
-
2
thumbcolor_resolved = Helpers::ResourceResolver.process_color(json_data['thumbTintColor'], required_imports)
-
2
colors_params << "thumbColor = #{thumbcolor_resolved}"
-
end
-
-
4
if json_data['minimumTrackTintColor']
-
2
activetrackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['minimumTrackTintColor'], required_imports)
-
2
colors_params << "activeTrackColor = #{activetrackcolor_resolved}"
-
end
-
-
4
if json_data['maximumTrackTintColor']
-
2
inactivetrackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['maximumTrackTintColor'], required_imports)
-
2
colors_params << "inactiveTrackColor = #{inactivetrackcolor_resolved}"
-
end
-
-
4
if colors_params.any?
-
4
code += ",\n" + indent("colors = SliderDefaults.colors(", depth + 1)
-
10
code += "\n" + colors_params.map { |param| indent(param, depth + 2) }.join(",\n")
-
4
code += "\n" + indent(")", depth + 1)
-
end
-
end
-
-
# Handle enabled attribute
-
23
if json_data.key?('enabled')
-
3
if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
-
1
variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
-
1
code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
-
else
-
2
code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
-
end
-
end
-
-
23
code += "\n" + indent(")", depth)
-
23
code
-
end
-
-
1
private
-
-
1
def self.indent(text, level)
-
136
return text if level == 0
-
89
spaces = ' ' * level
-
89
text.split("\n").map { |line|
-
90
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
# SwitchComponent handles both Switch (primary) and Toggle (alias) component types
-
# Switch is the primary component name. Toggle is supported as an alias for backward compatibility.
-
1
class SwitchComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
# Switch/Toggle uses 'isOn', 'value', 'checked', or 'bind' for binding
-
# Priority: isOn > value > checked > bind
-
7
state_attr = json_data['isOn'] || json_data['value'] || json_data['checked']
-
7
checked = if state_attr
-
if state_attr.is_a?(String) && state_attr.match(/@\{([^}]+)\}/)
-
variable = $1
-
"data.#{variable}"
-
else
-
# Direct boolean value
-
state_attr.to_s
-
end
-
7
elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
variable = $1
-
"data.#{variable}"
-
else
-
7
'false'
-
end
-
-
7
has_label = json_data['labelAttributes']
-
-
7
if has_label
-
generate_with_label(json_data, depth, required_imports, parent_type, checked)
-
else
-
7
generate_switch_only(json_data, depth, required_imports, parent_type, checked)
-
end
-
end
-
-
1
def self.generate_switch_only(json_data, depth, required_imports, parent_type, checked)
-
7
code = indent("Switch(", depth)
-
7
code += "\n" + indent("checked = #{checked},", depth + 1)
-
-
# onCheckedChange handler
-
7
binding_variable = nil
-
7
state_attr_val = json_data['isOn'] || json_data['value'] || json_data['checked']
-
7
if state_attr_val.is_a?(String) && state_attr_val.match(/@\{([^}]+)\}/)
-
binding_variable = $1
-
7
elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
binding_variable = $1
-
end
-
-
# onToggle and onValueChange are aliases
-
# onValueChange (camelCase) -> binding format only (@{functionName})
-
7
handler = json_data['onValueChange'] || json_data['onToggle']
-
7
if handler
-
# onValueChange must be binding format
-
if Helpers::ModifierBuilder.is_binding?(handler)
-
method_name = Helpers::ModifierBuilder.extract_binding_property(handler)
-
code += "\n" + indent("onCheckedChange = { viewModel.#{method_name}(it) },", depth + 1)
-
else
-
code += "\n" + indent("onCheckedChange = { // ERROR: #{handler} - camelCase events require binding format @{functionName} },", depth + 1)
-
end
-
7
elsif binding_variable
-
# Update the bound variable
-
code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue)) },", depth + 1)
-
else
-
7
code += "\n" + indent("onCheckedChange = { },", depth + 1)
-
end
-
-
# Build modifiers
-
7
modifiers = []
-
7
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
7
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
7
modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
-
-
# Add weight modifier if in Row or Column
-
7
if parent_type == 'Row' || parent_type == 'Column'
-
modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
-
end
-
-
7
code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
-
-
# Switch colors
-
# tint and tintColor are aliases for onTintColor
-
7
track_color = json_data['onTintColor'] || json_data['tint'] || json_data['tintColor']
-
7
if track_color || json_data['thumbTintColor']
-
1
required_imports&.add(:switch_colors)
-
1
colors_params = []
-
-
1
if track_color
-
1
checkedtrackcolor_resolved = Helpers::ResourceResolver.process_color(track_color, required_imports)
-
1
colors_params << "checkedTrackColor = #{checkedtrackcolor_resolved}"
-
end
-
-
1
if json_data['thumbTintColor']
-
checkedthumbcolor_resolved = Helpers::ResourceResolver.process_color(json_data['thumbTintColor'], required_imports)
-
colors_params << "checkedThumbColor = #{checkedthumbcolor_resolved}"
-
end
-
-
1
if colors_params.any?
-
1
code += ",\n" + indent("colors = SwitchDefaults.colors(", depth + 1)
-
2
code += "\n" + colors_params.map { |param| indent(param, depth + 2) }.join(",\n")
-
1
code += "\n" + indent(")", depth + 1)
-
end
-
end
-
-
# Handle enabled attribute
-
7
if json_data.key?('enabled')
-
if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
-
variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
-
code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
-
else
-
code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
-
end
-
end
-
-
7
code += "\n" + indent(")", depth)
-
7
code
-
end
-
-
1
def self.generate_with_label(json_data, depth, required_imports, parent_type, checked)
-
label_attrs = json_data['labelAttributes']
-
-
# Row container for label + switch
-
code = indent("Row(", depth)
-
code += "\n" + indent("verticalAlignment = Alignment.CenterVertically,", depth + 1)
-
-
# Build modifiers for Row
-
modifiers = []
-
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
if parent_type == 'Row' || parent_type == 'Column'
-
modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
-
end
-
-
code += Helpers::ModifierBuilder.format(modifiers, depth) if modifiers.any?
-
code += "\n" + indent(") {", depth)
-
-
# Label Text
-
label_text = label_attrs['text'] || ''
-
code += "\n" + indent("Text(", depth + 1)
-
code += "\n" + indent("text = \"#{label_text}\",", depth + 2)
-
-
# Font attributes
-
if label_attrs['fontSize']
-
code += "\n" + indent("fontSize = #{label_attrs['fontSize']}.sp,", depth + 2)
-
end
-
-
if label_attrs['fontColor']
-
font_color = Helpers::ResourceResolver.process_color(label_attrs['fontColor'], required_imports)
-
code += "\n" + indent("color = #{font_color},", depth + 2)
-
end
-
-
if label_attrs['font']
-
font_weight = label_attrs['font'].downcase == 'bold' ? 'FontWeight.Bold' : 'FontWeight.Normal'
-
code += "\n" + indent("fontWeight = #{font_weight},", depth + 2)
-
end
-
-
code += "\n" + indent("modifier = Modifier.weight(1f)", depth + 2)
-
code += "\n" + indent(")", depth + 1)
-
-
# Switch
-
code += "\n" + indent("Switch(", depth + 1)
-
code += "\n" + indent("checked = #{checked},", depth + 2)
-
-
# onCheckedChange handler
-
binding_variable = nil
-
state_attr_val = json_data['isOn'] || json_data['value'] || json_data['checked']
-
if state_attr_val.is_a?(String) && state_attr_val.match(/@\{([^}]+)\}/)
-
binding_variable = $1
-
elsif json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
binding_variable = $1
-
end
-
-
handler = json_data['onValueChange'] || json_data['onToggle']
-
if handler
-
# onValueChange (camelCase) -> binding format only (@{functionName})
-
if Helpers::ModifierBuilder.is_binding?(handler)
-
method_name = Helpers::ModifierBuilder.extract_binding_property(handler)
-
code += "\n" + indent("onCheckedChange = { viewModel.#{method_name}(it) }", depth + 2)
-
else
-
code += "\n" + indent("onCheckedChange = { // ERROR: #{handler} - camelCase events require binding format @{functionName} }", depth + 2)
-
end
-
elsif binding_variable
-
code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{binding_variable}\" to newValue)) }", depth + 2)
-
else
-
code += "\n" + indent("onCheckedChange = { }", depth + 2)
-
end
-
-
# Switch colors
-
track_color = json_data['onTintColor'] || json_data['tint'] || json_data['tintColor']
-
if track_color || json_data['thumbTintColor']
-
required_imports&.add(:switch_colors)
-
colors_params = []
-
-
if track_color
-
checkedtrackcolor_resolved = Helpers::ResourceResolver.process_color(track_color, required_imports)
-
colors_params << "checkedTrackColor = #{checkedtrackcolor_resolved}"
-
end
-
-
if json_data['thumbTintColor']
-
checkedthumbcolor_resolved = Helpers::ResourceResolver.process_color(json_data['thumbTintColor'], required_imports)
-
colors_params << "checkedThumbColor = #{checkedthumbcolor_resolved}"
-
end
-
-
if colors_params.any?
-
code += ",\n" + indent("colors = SwitchDefaults.colors(", depth + 2)
-
code += "\n" + colors_params.map { |param| indent(param, depth + 3) }.join(",\n")
-
code += "\n" + indent(")", depth + 2)
-
end
-
end
-
-
# Handle enabled attribute
-
if json_data.key?('enabled')
-
if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
-
variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
-
code += ",\n" + indent("enabled = data.#{variable}", depth + 2)
-
else
-
code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 2)
-
end
-
end
-
-
code += "\n" + indent(")", depth + 1)
-
code += "\n" + indent("}", depth)
-
code
-
end
-
-
1
private
-
-
1
def self.indent(text, level)
-
31
return text if level == 0
-
17
spaces = ' ' * level
-
17
text.split("\n").map { |line|
-
17
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class TableComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
21
required_imports&.add(:lazy_column)
-
-
# Table uses data binding for items
-
21
items = if json_data['bind'] && json_data['bind'].match(/@\{([^}]+)\}/)
-
1
variable = $1
-
1
"data.#{variable}"
-
20
elsif json_data['items'] && json_data['items'].match(/@\{([^}]+)\}/)
-
1
variable = $1
-
1
"data.#{variable}"
-
else
-
19
'emptyList()'
-
end
-
-
21
code = indent("LazyColumn(", depth)
-
-
# Content padding
-
21
if json_data['contentPadding']
-
2
padding = json_data['contentPadding']
-
2
if padding.is_a?(Array) && padding.length == 4
-
1
code += "\n" + indent("contentPadding = PaddingValues(top = #{padding[0]}.dp, end = #{padding[1]}.dp, bottom = #{padding[2]}.dp, start = #{padding[3]}.dp),", depth + 1)
-
1
elsif padding.is_a?(Numeric)
-
1
code += "\n" + indent("contentPadding = PaddingValues(#{padding}.dp),", depth + 1)
-
end
-
end
-
-
# Vertical arrangement (spacing between rows)
-
21
if json_data['rowSpacing'] || json_data['spacing']
-
2
required_imports&.add(:arrangement)
-
2
spacing = json_data['rowSpacing'] || json_data['spacing'] || 0
-
2
code += "\n" + indent("verticalArrangement = Arrangement.spacedBy(#{spacing}.dp),", depth + 1)
-
end
-
-
# Build modifiers
-
21
modifiers = []
-
21
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
21
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
21
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
21
modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
-
-
21
code += Helpers::ModifierBuilder.format(modifiers, depth)
-
-
21
code += "\n" + indent(") {", depth)
-
-
# Table header if specified
-
21
if json_data['header']
-
4
code += "\n" + indent("item {", depth + 1)
-
4
code += generate_header_row(json_data['header'], depth + 2, required_imports)
-
4
code += "\n" + indent("}", depth + 1)
-
-
# Divider after header
-
4
if json_data['separatorStyle'] != 'none'
-
3
code += "\n" + indent("item {", depth + 1)
-
3
code += "\n" + indent("Divider(", depth + 2)
-
3
code += "\n" + indent("color = Color.LightGray,", depth + 3)
-
3
code += "\n" + indent("thickness = 1.dp", depth + 3)
-
3
code += "\n" + indent(")", depth + 2)
-
3
code += "\n" + indent("}", depth + 1)
-
end
-
end
-
-
# Table rows
-
21
code += "\n" + indent("items(#{items}) { item ->", depth + 1)
-
-
# Row content
-
21
if json_data['cell']
-
# Custom cell template
-
1
cell_content = generate_table_cell(json_data['cell'], depth + 2, required_imports)
-
1
code += "\n" + cell_content
-
else
-
# Default row
-
20
code += generate_default_row(json_data, depth + 2, required_imports)
-
end
-
-
# Separator between rows
-
21
if json_data['separatorStyle'] != 'none'
-
19
code += "\n" + indent("Divider(", depth + 2)
-
-
# Separator inset
-
19
if json_data['separatorInset']
-
2
inset = json_data['separatorInset']
-
2
if inset.is_a?(Hash)
-
2
start_padding = inset['left'] || inset['start'] || 0
-
2
code += "\n" + indent("modifier = Modifier.padding(start = #{start_padding}.dp),", depth + 3)
-
end
-
end
-
-
19
code += "\n" + indent("color = Color.LightGray,", depth + 3)
-
19
code += "\n" + indent("thickness = 0.5.dp", depth + 3)
-
19
code += "\n" + indent(")", depth + 2)
-
end
-
-
21
code += "\n" + indent("}", depth + 1)
-
-
21
code += "\n" + indent("}", depth)
-
21
code
-
end
-
-
1
private
-
-
1
def self.generate_header_row(header_data, depth, required_imports)
-
4
code = indent("Row(", depth)
-
4
code += "\n" + indent("modifier = Modifier", depth + 1)
-
4
code += "\n" + indent(" .fillMaxWidth()", depth + 1)
-
4
code += "\n" + indent(" .padding(horizontal = 16.dp, vertical = 12.dp),", depth + 1)
-
4
code += "\n" + indent("horizontalArrangement = Arrangement.SpaceBetween", depth + 1)
-
4
code += "\n" + indent(") {", depth)
-
-
4
if header_data.is_a?(Array)
-
3
header_data.each do |column|
-
5
code += "\n" + indent("Text(", depth + 1)
-
5
code += "\n" + indent("text = \"#{column}\",", depth + 2)
-
5
code += "\n" + indent("fontWeight = FontWeight.Bold,", depth + 2)
-
5
code += "\n" + indent("modifier = Modifier.weight(1f)", depth + 2)
-
5
code += "\n" + indent(")", depth + 1)
-
end
-
else
-
1
code += "\n" + indent("Text(text = \"Header\", fontWeight = FontWeight.Bold)", depth + 1)
-
end
-
-
4
code += "\n" + indent("}", depth)
-
4
code
-
end
-
-
1
def self.generate_table_cell(cell_data, depth, required_imports)
-
1
code = indent("Row(", depth)
-
1
code += "\n" + indent("modifier = Modifier", depth + 1)
-
1
code += "\n" + indent(" .fillMaxWidth()", depth + 1)
-
1
code += "\n" + indent(" .clickable { /* Handle row click */ }", depth + 1)
-
1
code += "\n" + indent(" .padding(horizontal = 16.dp, vertical = 12.dp)", depth + 1)
-
1
code += "\n" + indent(") {", depth)
-
-
# Cell content based on template
-
1
code += "\n" + indent("// Custom cell rendering", depth + 1)
-
1
code += "\n" + indent("Text(text = item.toString())", depth + 1)
-
-
1
code += "\n" + indent("}", depth)
-
1
code
-
end
-
-
1
def self.generate_default_row(json_data, depth, required_imports)
-
20
row_height = json_data['rowHeight'] || 60
-
-
20
code = "\n" + indent("Row(", depth)
-
20
code += "\n" + indent("modifier = Modifier", depth + 1)
-
20
code += "\n" + indent(" .fillMaxWidth()", depth + 1)
-
20
code += "\n" + indent(" .height(#{row_height}.dp)", depth + 1)
-
20
code += "\n" + indent(" .clickable { /* Handle row click */ }", depth + 1)
-
20
code += "\n" + indent(" .padding(horizontal = 16.dp),", depth + 1)
-
20
code += "\n" + indent("verticalAlignment = Alignment.CenterVertically", depth + 1)
-
20
code += "\n" + indent(") {", depth)
-
20
code += "\n" + indent("Text(text = item.toString())", depth + 1)
-
20
code += "\n" + indent("}", depth)
-
20
code
-
end
-
-
1
def self.indent(text, level)
-
479
return text if level == 0
-
415
spaces = ' ' * level
-
415
text.split("\n").map { |line|
-
417
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class TabviewComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
# TabView maps to TabRow with Tab items in Compose
-
12
required_imports&.add(:tab_row)
-
12
required_imports&.add(:remember_state)
-
-
# Generate state variable for selected tab
-
12
state_var = "selectedTab_#{Time.now.to_i}_#{rand(1000)}"
-
-
12
code = indent("// Tab view with content", depth)
-
12
code += "\n" + indent("var #{state_var} by remember { mutableStateOf(0) }", depth)
-
12
code += "\n\n" + indent("Column(", depth)
-
-
# Column modifiers
-
12
modifiers = []
-
12
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
12
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
12
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
12
code += Helpers::ModifierBuilder.format(modifiers, depth)
-
12
code += "\n" + indent(") {", depth)
-
-
# TabRow
-
12
code += "\n" + indent("TabRow(", depth + 1)
-
12
code += "\n" + indent("selectedTabIndex = #{state_var},", depth + 2)
-
-
# TabRow modifiers
-
12
tab_modifiers = []
-
12
if json_data['backgroundColor']
-
tab_modifiers << ".background(Helpers::ResourceResolver.process_color('#{json_data['backgroundColor']}', required_imports))"
-
end
-
-
12
if tab_modifiers.any?
-
code += "\n" + indent("modifier = Modifier", depth + 2)
-
tab_modifiers.each do |mod|
-
code += "\n" + indent(mod, depth + 3)
-
end
-
code += ","
-
end
-
-
12
code += "\n" + indent(") {", depth + 1)
-
-
# Generate tabs from items
-
12
if json_data['items'] && json_data['items'].is_a?(Array)
-
12
json_data['items'].each_with_index do |item, index|
-
16
title = item['title'] || "Tab #{index + 1}"
-
16
code += "\n" + indent("Tab(", depth + 2)
-
16
code += "\n" + indent("selected = #{state_var} == #{index},", depth + 3)
-
16
code += "\n" + indent("onClick = { #{state_var} = #{index} },", depth + 3)
-
16
code += "\n" + indent("text = { Text(\"#{title}\") }", depth + 3)
-
16
code += "\n" + indent(")", depth + 2)
-
end
-
end
-
-
12
code += "\n" + indent("}", depth + 1)
-
-
# Tab content using when expression
-
12
if json_data['items'] && json_data['items'].is_a?(Array)
-
12
code += "\n\n" + indent("// Tab content", depth + 1)
-
12
code += "\n" + indent("when (#{state_var}) {", depth + 1)
-
-
12
json_data['items'].each_with_index do |item, index|
-
16
code += "\n" + indent("#{index} -> {", depth + 2)
-
-
# Content for each tab
-
16
if item['child']
-
1
code += "\n" + indent("// Content for tab #{index}", depth + 3)
-
# Note: Actual child content would be generated by the parent
-
else
-
15
code += "\n" + indent("Text(\"Content for #{item['title'] || "Tab #{index + 1}"}\")", depth + 3)
-
end
-
-
16
code += "\n" + indent("}", depth + 2)
-
end
-
-
12
code += "\n" + indent("}", depth + 1)
-
end
-
-
12
code += "\n" + indent("}", depth)
-
12
code
-
end
-
-
1
private
-
-
1
def self.indent(text, level)
-
275
return text if level == 0
-
214
spaces = ' ' * level
-
214
text.split("\n").map { |line|
-
214
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/visibility_helper'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
# Text Component Generator
-
#
-
# NOTE: Label is the primary component name in JsonUI.
-
# Text is supported as an alias for backward compatibility.
-
# Both "type": "Label" and "type": "Text" work identically.
-
#
-
1
class TextComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
# Check if component should be skipped entirely (static gone/hidden)
-
62
return "" if Helpers::VisibilityHelper.should_skip_render?(json_data)
-
-
# Check if we need to use PartialAttributesText for partial attributes
-
61
if json_data['partialAttributes'] && json_data['partialAttributes'].any?
-
8
return generate_with_partial_attributes_component(json_data, depth, required_imports, parent_type)
-
end
-
-
# Check if we need to use PartialAttributesText for linkable attribute
-
53
if json_data['linkable']
-
9
return generate_with_partial_attributes_for_linkable(json_data, depth, required_imports, parent_type)
-
end
-
-
44
text = Helpers::ResourceResolver.process_text(json_data['text'] || '', required_imports)
-
-
44
component_code = indent("Text(", depth)
-
44
component_code += "\n" + indent("text = #{text},", depth + 1)
-
-
# Font size
-
44
if json_data['fontSize']
-
2
component_code += "\n" + indent("fontSize = #{json_data['fontSize']}.sp,", depth + 1)
-
end
-
-
# Font color (official attribute)
-
44
if json_data['fontColor']
-
1
color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
-
1
component_code += "\n" + indent("color = #{color_value},", depth + 1) if color_value
-
end
-
-
# Font weight values that should use system font weight
-
44
weight_names = ['bold', 'semibold', 'medium', 'light', 'thin', 'extralight', 'heavy', 'black', 'normal']
-
44
weight_mapping = {
-
'thin' => 'Thin',
-
'extralight' => 'ExtraLight',
-
'light' => 'Light',
-
'normal' => 'Normal',
-
'medium' => 'Medium',
-
'semibold' => 'SemiBold',
-
'bold' => 'Bold',
-
'extrabold' => 'ExtraBold',
-
'heavy' => 'ExtraBold',
-
'black' => 'Black'
-
}
-
-
# Handle font attribute - can be weight name or custom font family
-
44
if json_data['font']
-
4
font_value = json_data['font'].to_s.downcase
-
4
if weight_names.include?(font_value)
-
# It's a weight name, use FontWeight
-
3
weight = weight_mapping[font_value] || 'Normal'
-
3
required_imports&.add(:font_weight)
-
3
component_code += "\n" + indent("fontWeight = FontWeight.#{weight},", depth + 1)
-
else
-
# It's a custom font family name
-
1
required_imports&.add(:font_family)
-
1
component_code += "\n" + indent("fontFamily = FontFamily(Font(R.font.#{json_data['font'].gsub('-', '_').downcase})),", depth + 1)
-
end
-
40
elsif json_data['fontWeight']
-
# fontWeight attribute takes precedence if font not specified
-
7
weight = weight_mapping[json_data['fontWeight'].downcase] || json_data['fontWeight'].capitalize
-
7
required_imports&.add(:font_weight)
-
7
component_code += "\n" + indent("fontWeight = FontWeight.#{weight},", depth + 1)
-
end
-
-
# Text decoration (underline, strikethrough)
-
44
text_decorations = []
-
44
if json_data['underline']
-
2
required_imports&.add(:text_decoration)
-
2
text_decorations << "TextDecoration.Underline"
-
end
-
-
44
if json_data['strikethrough']
-
2
required_imports&.add(:text_decoration)
-
2
text_decorations << "TextDecoration.LineThrough"
-
end
-
-
44
if text_decorations.any?
-
3
if text_decorations.length > 1
-
1
component_code += "\n" + indent("textDecoration = TextDecoration.combine(listOf(#{text_decorations.join(', ')})),", depth + 1)
-
else
-
2
component_code += "\n" + indent("textDecoration = #{text_decorations.first},", depth + 1)
-
end
-
end
-
-
# Text shadow and line height
-
44
style_parts = []
-
-
44
if json_data['textShadow']
-
1
required_imports&.add(:shadow_style)
-
1
style_parts << "shadow = Shadow(color = Color.Black, offset = Offset(2f, 2f), blurRadius = 4f)"
-
end
-
-
44
if json_data['lineHeightMultiple']
-
1
required_imports&.add(:text_style)
-
# Line height multiplier - apply to font size
-
1
line_height = json_data['fontSize'] ? json_data['fontSize'].to_f * json_data['lineHeightMultiple'].to_f : 14.0 * json_data['lineHeightMultiple'].to_f
-
1
style_parts << "lineHeight = #{line_height}.sp"
-
43
elsif json_data['lineSpacing']
-
required_imports&.add(:text_style)
-
# Line spacing - add to base font size
-
base_size = json_data['fontSize'] ? json_data['fontSize'].to_f : 14.0
-
line_height = base_size + json_data['lineSpacing'].to_f
-
style_parts << "lineHeight = #{line_height}.sp"
-
end
-
-
44
if style_parts.any?
-
2
required_imports&.add(:text_style)
-
2
component_code += "\n" + indent("style = TextStyle(#{style_parts.join(', ')}),", depth + 1)
-
end
-
-
# Build modifiers
-
44
modifiers = []
-
-
# Get visibility info (but don't add to modifiers, will be handled by wrapper)
-
44
visibility_result = Helpers::ModifierBuilder.build_visibility(json_data, required_imports)
-
44
modifiers.concat(visibility_result[:modifiers]) if visibility_result[:modifiers].any?
-
-
44
modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
-
-
# Add weight modifier if in Row or Column
-
44
if parent_type == 'Row' || parent_type == 'Column'
-
2
modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
-
end
-
-
# 1. Add size first (total size including padding)
-
44
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
-
# 2. Add margins (outside spacing)
-
44
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
# 3. Add shadow before background
-
44
modifiers.concat(Helpers::ModifierBuilder.build_shadow(json_data, required_imports))
-
-
# 4. Add background before padding (so padding creates space inside the background)
-
44
modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
-
-
# 5. Handle edgeInset for text-specific padding (inside spacing) - applied last
-
44
if json_data['edgeInset']
-
2
insets = json_data['edgeInset']
-
2
if insets.is_a?(Array) && insets.length == 4
-
1
modifiers << ".padding(top = #{insets[0]}.dp, end = #{insets[1]}.dp, bottom = #{insets[2]}.dp, start = #{insets[3]}.dp)"
-
1
elsif insets.is_a?(Numeric)
-
1
modifiers << ".padding(#{insets}.dp)"
-
end
-
else
-
42
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
end
-
-
# Format modifiers
-
44
if modifiers.any?
-
8
component_code += Helpers::ModifierBuilder.format(modifiers, depth)
-
else
-
36
component_code += "\n" + indent("modifier = Modifier", depth + 1)
-
end
-
-
# Text alignment
-
44
if json_data['textAlign']
-
3
required_imports&.add(:text_align)
-
3
case json_data['textAlign'].downcase
-
when 'center'
-
1
component_code += ",\n" + indent("textAlign = TextAlign.Center", depth + 1)
-
when 'right'
-
1
component_code += ",\n" + indent("textAlign = TextAlign.End", depth + 1)
-
when 'left'
-
1
component_code += ",\n" + indent("textAlign = TextAlign.Start", depth + 1)
-
end
-
41
elsif json_data['centerHorizontal']
-
1
required_imports&.add(:text_align)
-
1
component_code += ",\n" + indent("textAlign = TextAlign.Center", depth + 1)
-
end
-
-
# Lines (maxLines)
-
44
if json_data['lines']
-
2
if json_data['lines'] == 0
-
1
component_code += ",\n" + indent("maxLines = Int.MAX_VALUE", depth + 1)
-
else
-
1
component_code += ",\n" + indent("maxLines = #{json_data['lines']}", depth + 1)
-
end
-
end
-
-
# Auto shrink text
-
44
if json_data['autoShrink']
-
required_imports&.add(:text_overflow)
-
component_code += ",\n" + indent("softWrap = false", depth + 1)
-
component_code += ",\n" + indent("maxLines = 1", depth + 1)
-
component_code += ",\n" + indent("overflow = TextOverflow.Ellipsis", depth + 1)
-
end
-
-
# Minimum scale factor (auto-shrink text)
-
# In Compose, this is achieved with softWrap=false and overflow=Visible to allow text to scale
-
44
if json_data['minimumScaleFactor']
-
# Note: Compose doesn't have direct equivalent, but we can use single line with ellipsis
-
# or recommend using a custom composable. For now, we'll add a comment
-
1
component_code += ",\n" + indent("// minimumScaleFactor: #{json_data['minimumScaleFactor']} - Consider using AutoSizeText library", depth + 1)
-
1
component_code += ",\n" + indent("maxLines = 1", depth + 1)
-
1
required_imports&.add(:text_overflow)
-
1
component_code += ",\n" + indent("overflow = TextOverflow.Ellipsis", depth + 1)
-
end
-
-
# Line break mode (overflow)
-
44
if json_data['lineBreakMode']
-
3
required_imports&.add(:text_overflow)
-
3
case json_data['lineBreakMode'].downcase
-
when 'clip'
-
1
component_code += ",\n" + indent("overflow = TextOverflow.Clip", depth + 1)
-
when 'tail', 'word'
-
2
component_code += ",\n" + indent("overflow = TextOverflow.Ellipsis", depth + 1)
-
end
-
end
-
-
# highlightColor - color when pressed/selected
-
44
if json_data['highlightColor']
-
highlight_color = Helpers::ResourceResolver.process_color(json_data['highlightColor'], required_imports)
-
component_code += ",\n" + indent("// highlightColor: #{highlight_color} - Use InteractionSource for pressed state styling", depth + 1)
-
end
-
-
44
component_code += "\n" + indent(")", depth)
-
-
# Wrap with VisibilityWrapper if needed
-
44
Helpers::VisibilityHelper.wrap_with_visibility(json_data, component_code, depth, required_imports)
-
end
-
-
1
private
-
-
1
def self.generate_with_partial_attributes_for_linkable(json_data, depth, required_imports, parent_type)
-
9
required_imports&.add(:partial_attributes_text)
-
-
9
text = json_data['text'] || ''
-
-
9
code = indent("PartialAttributesText(", depth)
-
9
code += "\n" + indent("text = \"#{escape_string(text)}\",", depth + 1)
-
9
code += "\n" + indent("linkable = true,", depth + 1)
-
-
# Build style
-
9
style_parts = []
-
-
9
if json_data['fontSize']
-
1
style_parts << "fontSize = #{json_data['fontSize']}.sp"
-
end
-
-
9
if json_data['fontColor']
-
1
color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
-
1
style_parts << "color = #{color_value}" if color_value
-
end
-
-
# Handle font attribute for linkable text style
-
9
font_weight_result = resolve_font_attribute(json_data, required_imports)
-
9
style_parts << font_weight_result if font_weight_result
-
-
9
if json_data['textAlign']
-
3
required_imports&.add(:text_align)
-
3
case json_data['textAlign'].downcase
-
when 'center'
-
1
style_parts << "textAlign = TextAlign.Center"
-
when 'right'
-
1
style_parts << "textAlign = TextAlign.End"
-
when 'left'
-
1
style_parts << "textAlign = TextAlign.Start"
-
end
-
end
-
-
9
if style_parts.any?
-
6
required_imports&.add(:text_style)
-
6
code += "\n" + indent("style = TextStyle(#{style_parts.join(', ')}),", depth + 1)
-
end
-
-
# Build modifiers
-
9
modifiers = []
-
9
modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
-
9
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
# Handle edgeInset for text-specific padding
-
9
if json_data['edgeInset']
-
2
insets = json_data['edgeInset']
-
2
if insets.is_a?(Array) && insets.length == 4
-
1
modifiers << ".padding(top = #{insets[0]}.dp, end = #{insets[1]}.dp, bottom = #{insets[2]}.dp, start = #{insets[3]}.dp)"
-
1
elsif insets.is_a?(Numeric)
-
1
modifiers << ".padding(#{insets}.dp)"
-
end
-
else
-
7
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
end
-
-
# Add background
-
9
modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
-
9
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
-
9
if modifiers.any?
-
2
code += Helpers::ModifierBuilder.format(modifiers, depth)
-
else
-
7
code += "\n" + indent("modifier = Modifier", depth + 1)
-
end
-
-
9
code += "\n" + indent(")", depth)
-
-
# Wrap with VisibilityWrapper if needed
-
9
Helpers::VisibilityHelper.wrap_with_visibility(json_data, code, depth, required_imports)
-
end
-
-
1
def self.generate_with_partial_attributes_component(json_data, depth, required_imports, parent_type)
-
8
required_imports&.add(:partial_attributes_text)
-
-
8
text = json_data['text'] || ''
-
8
partial_attrs = json_data['partialAttributes']
-
-
8
code = indent("PartialAttributesText(", depth)
-
8
code += "\n" + indent("text = \"#{escape_string(text)}\",", depth + 1)
-
-
# Build partial attributes list
-
8
code += "\n" + indent("partialAttributes = listOf(", depth + 1)
-
-
8
partial_attrs.each_with_index do |attr, index|
-
9
code += "\n" + indent("PartialAttribute.fromJsonRange(", depth + 2)
-
-
# Handle range - can be array or string
-
9
range = attr['range']
-
9
if range.is_a?(Array)
-
8
code += "\n" + indent("range = listOf(#{range.join(', ')}),", depth + 3)
-
1
elsif range.is_a?(String)
-
1
code += "\n" + indent("range = \"#{escape_string(range)}\",", depth + 3)
-
end
-
-
9
code += "\n" + indent("text = \"#{escape_string(text)}\",", depth + 3)
-
-
# Add optional attributes
-
9
if attr['fontColor']
-
4
code += "\n" + indent("fontColor = \"#{attr['fontColor']}\",", depth + 3)
-
end
-
9
if attr['fontSize']
-
1
code += "\n" + indent("fontSize = #{attr['fontSize']},", depth + 3)
-
end
-
9
if attr['fontWeight']
-
1
code += "\n" + indent("fontWeight = \"#{attr['fontWeight']}\",", depth + 3)
-
end
-
9
if attr['background']
-
1
code += "\n" + indent("background = \"#{attr['background']}\",", depth + 3)
-
end
-
9
if attr['underline']
-
1
code += "\n" + indent("underline = #{attr['underline']},", depth + 3)
-
end
-
9
if attr['strikethrough']
-
1
code += "\n" + indent("strikethrough = #{attr['strikethrough']},", depth + 3)
-
end
-
# Handle click events for partial attributes
-
# onclick (lowercase) -> selector format (string only)
-
# onClick (camelCase) -> binding format only (@{functionName})
-
9
if attr['onclick']
-
1
handler_call = Helpers::ModifierBuilder.get_event_handler_call(attr['onclick'], is_camel_case: false)
-
1
code += "\n" + indent("onClick = { #{handler_call} }", depth + 3)
-
8
elsif attr['onClick']
-
handler_call = Helpers::ModifierBuilder.get_event_handler_call(attr['onClick'], is_camel_case: true)
-
code += "\n" + indent("onClick = { #{handler_call} }", depth + 3)
-
else
-
8
code += "\n" + indent("onClick = null", depth + 3)
-
end
-
-
9
code += "\n" + indent(")!!", depth + 2) # !! because fromJsonRange returns nullable
-
9
code += "," if index < partial_attrs.length - 1
-
end
-
-
8
code += "\n" + indent("),", depth + 1)
-
-
# Build modifiers
-
8
modifiers = []
-
8
modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
-
8
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
8
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
8
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
-
8
if modifiers.any?
-
code += Helpers::ModifierBuilder.format(modifiers, depth)
-
else
-
8
code += "\n" + indent("modifier = Modifier", depth + 1)
-
end
-
-
# Add style
-
8
style_parts = []
-
8
style_parts << "fontSize = #{json_data['fontSize']}.sp" if json_data['fontSize']
-
-
8
if json_data['fontColor']
-
color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
-
style_parts << "color = #{color_value}" if color_value
-
end
-
-
8
if json_data['textAlign']
-
required_imports&.add(:text_align)
-
case json_data['textAlign'].downcase
-
when 'center'
-
style_parts << "textAlign = TextAlign.Center"
-
when 'right'
-
style_parts << "textAlign = TextAlign.End"
-
when 'left'
-
style_parts << "textAlign = TextAlign.Start"
-
end
-
end
-
-
8
if style_parts.any?
-
required_imports&.add(:text_style)
-
code += ",\n" + indent("style = TextStyle(#{style_parts.join(', ')})", depth + 1)
-
end
-
-
8
code += "\n" + indent(")", depth)
-
-
# Wrap with VisibilityWrapper if needed
-
8
Helpers::VisibilityHelper.wrap_with_visibility(json_data, code, depth, required_imports)
-
end
-
-
1
def self.generate_with_partial_attributes(json_data, depth, required_imports, parent_type)
-
required_imports&.add(:annotated_string)
-
required_imports&.add(:clickable_text)
-
required_imports&.add(:remember_state)
-
-
text = json_data['text'] || ''
-
partial_attrs = json_data['partialAttributes']
-
-
# Build AnnotatedString as a variable first
-
code = indent("val annotatedText = buildAnnotatedString {", depth)
-
code += "\n" + indent("append(\"#{escape_string(text)}\")", depth + 1)
-
-
# Apply partial attributes
-
partial_attrs.each do |attr|
-
range = attr['range']
-
next unless range && range.is_a?(Array) && range.length == 2
-
-
start_idx = range[0]
-
end_idx = range[1]
-
-
# Build SpanStyle for this range
-
span_styles = []
-
-
if attr['fontColor']
-
color_resolved = Helpers::ResourceResolver.process_color(attr['fontColor'], required_imports)
-
span_styles << "color = #{color_resolved}"
-
end
-
-
if attr['fontSize']
-
span_styles << "fontSize = #{attr['fontSize']}.sp"
-
end
-
-
if attr['fontWeight']
-
weight_mapping = {
-
'bold' => 'Bold',
-
'semibold' => 'SemiBold',
-
'medium' => 'Medium',
-
'light' => 'Light'
-
}
-
weight = weight_mapping[attr['fontWeight'].downcase] || 'Normal'
-
span_styles << "fontWeight = FontWeight.#{weight}"
-
end
-
-
if attr['background']
-
background_resolved = Helpers::ResourceResolver.process_color(attr['background'], required_imports)
-
span_styles << "background = #{background_resolved}"
-
end
-
-
if attr['underline']
-
required_imports&.add(:text_decoration)
-
span_styles << "textDecoration = TextDecoration.Underline"
-
end
-
-
if attr['strikethrough']
-
required_imports&.add(:text_decoration)
-
span_styles << "textDecoration = TextDecoration.LineThrough"
-
end
-
-
if span_styles.any?
-
code += "\n" + indent("addStyle(", depth + 1)
-
code += "\n" + indent("style = SpanStyle(#{span_styles.join(', ')}),", depth + 2)
-
code += "\n" + indent("start = #{start_idx},", depth + 2)
-
code += "\n" + indent("end = #{end_idx}", depth + 2)
-
code += "\n" + indent(")", depth + 1)
-
end
-
-
# Add clickable annotation if onclick/onClick is specified
-
click_handler = attr['onclick'] || attr['onClick']
-
if click_handler
-
# Extract method name from binding format if needed
-
method_name = if click_handler.match?(/^@\{(.+)\}$/)
-
click_handler.match(/^@\{(.+)\}$/)[1]
-
else
-
click_handler.gsub(':', '')
-
end
-
code += "\n" + indent("addStringAnnotation(", depth + 1)
-
code += "\n" + indent("tag = \"CLICKABLE\",", depth + 2)
-
code += "\n" + indent("annotation = \"#{method_name}\",", depth + 2)
-
code += "\n" + indent("start = #{start_idx},", depth + 2)
-
code += "\n" + indent("end = #{end_idx}", depth + 2)
-
code += "\n" + indent(")", depth + 1)
-
end
-
end
-
-
code += "\n" + indent("}", depth)
-
code += "\n"
-
-
# Now use ClickableText with the annotatedString
-
code += indent("ClickableText(", depth)
-
code += "\n" + indent("text = annotatedText,", depth + 1)
-
-
# Add onClick handler for clickable ranges
-
if partial_attrs.any? { |attr| attr['onclick'] }
-
code += "\n" + indent("onClick = { offset ->", depth + 1)
-
code += "\n" + indent("annotatedText.getStringAnnotations(\"CLICKABLE\", offset, offset)", depth + 2)
-
code += "\n" + indent(".firstOrNull()?.let { annotation ->", depth + 3)
-
code += "\n" + indent("viewModel.handlePartialClick(annotation.item)", depth + 4)
-
code += "\n" + indent("}", depth + 3)
-
code += "\n" + indent("},", depth + 1)
-
else
-
code += "\n" + indent("onClick = { },", depth + 1)
-
end
-
-
# Add style (fontSize, color, etc. for the whole text)
-
style_code = build_text_style(json_data, depth + 1, required_imports)
-
if style_code
-
code += style_code
-
end
-
-
# Build modifiers
-
modifiers = []
-
modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
-
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
-
if modifiers.any?
-
code += Helpers::ModifierBuilder.format(modifiers, depth)
-
else
-
code += "\n" + indent("modifier = Modifier", depth + 1)
-
end
-
-
code += "\n" + indent(")", depth)
-
-
# Wrap with VisibilityWrapper if needed
-
Helpers::VisibilityHelper.wrap_with_visibility(json_data, code, depth, required_imports)
-
end
-
-
1
def self.build_text_style(json_data, depth, required_imports)
-
4
style_parts = []
-
-
4
if json_data['fontSize']
-
1
style_parts << "fontSize = #{json_data['fontSize']}.sp"
-
end
-
-
4
if json_data['fontColor']
-
1
color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
-
1
style_parts << "color = #{color_value}" if color_value
-
end
-
-
4
if json_data['textAlign']
-
1
required_imports&.add(:text_align)
-
1
case json_data['textAlign'].downcase
-
when 'center'
-
1
style_parts << "textAlign = TextAlign.Center"
-
when 'right'
-
style_parts << "textAlign = TextAlign.End"
-
when 'left'
-
style_parts << "textAlign = TextAlign.Start"
-
end
-
end
-
-
4
if style_parts.any?
-
3
required_imports&.add(:text_style)
-
3
return ",\n" + indent("style = TextStyle(#{style_parts.join(', ')})", depth)
-
end
-
-
nil
-
end
-
-
# Resolve font attribute - returns style string for fontWeight or fontFamily
-
1
def self.resolve_font_attribute(json_data, required_imports)
-
9
weight_names = ['bold', 'semibold', 'medium', 'light', 'thin', 'extralight', 'heavy', 'black', 'normal']
-
9
weight_mapping = {
-
'thin' => 'Thin',
-
'extralight' => 'ExtraLight',
-
'light' => 'Light',
-
'normal' => 'Normal',
-
'medium' => 'Medium',
-
'semibold' => 'SemiBold',
-
'bold' => 'Bold',
-
'extrabold' => 'ExtraBold',
-
'heavy' => 'ExtraBold',
-
'black' => 'Black'
-
}
-
-
9
if json_data['font']
-
font_value = json_data['font'].to_s.downcase
-
if weight_names.include?(font_value)
-
weight = weight_mapping[font_value] || 'Normal'
-
required_imports&.add(:font_weight)
-
"fontWeight = FontWeight.#{weight}"
-
else
-
required_imports&.add(:font_family)
-
"fontFamily = FontFamily(Font(R.font.#{json_data['font'].gsub('-', '_').downcase}))"
-
end
-
9
elsif json_data['fontWeight']
-
1
weight = weight_mapping[json_data['fontWeight'].downcase] || json_data['fontWeight'].capitalize
-
1
required_imports&.add(:font_weight)
-
1
"fontWeight = FontWeight.#{weight}"
-
end
-
end
-
-
1
def self.escape_string(text)
-
32
text.gsub('\\', '\\\\\\\\')
-
.gsub('"', '\\"')
-
.gsub("\n", '\\n')
-
.gsub("\r", '\\r')
-
.gsub("\t", '\\t')
-
end
-
-
1
def self.quote(text)
-
# Escape special characters properly
-
2
escaped = text.gsub('\\', '\\\\\\\\') # Escape backslashes first
-
.gsub('"', '\\"') # Escape quotes
-
.gsub("\n", '\\n') # Escape newlines
-
.gsub("\r", '\\r') # Escape carriage returns
-
.gsub("\t", '\\t') # Escape tabs
-
2
"\"#{escaped}\""
-
end
-
-
1
def self.indent(text, level)
-
356
return text if level == 0
-
236
spaces = ' ' * level
-
236
text.split("\n").map { |line|
-
238
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class TextFieldComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
# TextField uses 'text' for value and supports both 'hint' and 'placeholder'
-
# For TextField value, we need direct data binding (not string interpolation)
-
39
raw_text = json_data['text'] || ''
-
39
value = if raw_text.match(/@\{([^}]+)\}/)
-
3
variable = $1
-
3
var_name = variable.include?(' ?? ') ? variable.split(' ?? ')[0].strip : variable
-
3
"data.#{var_name}"
-
else
-
36
Helpers::ResourceResolver.process_text(raw_text, required_imports)
-
end
-
39
placeholder_text = json_data['hint'] || json_data['placeholder'] || ''
-
39
placeholder = placeholder_text.empty? ? '""' : Helpers::ResourceResolver.process_text(placeholder_text, required_imports)
-
39
is_secure = json_data['secure'] == true
-
-
# Check if we need to wrap in Box for margins
-
39
has_margins = json_data['margins'] || json_data['topMargin'] || json_data['bottomMargin'] ||
-
json_data['leftMargin'] || json_data['rightMargin']
-
-
# Always use CustomTextField
-
39
required_imports&.add(:custom_textfield)
-
39
required_imports&.add(:visual_transformation) if is_secure
-
-
39
code = ""
-
39
if has_margins
-
2
required_imports&.add(:box)
-
2
code = indent("CustomTextFieldWithMargins(", depth)
-
else
-
37
code = indent("CustomTextField(", depth)
-
end
-
-
39
code += "\n" + indent("value = #{value},", depth + 1)
-
-
# Handle onValueChange/onTextChange
-
# Priority: onTextChange (explicit handler) > data binding > empty
-
39
if json_data['onTextChange']
-
# Explicit event handler
-
1
code += "\n" + indent("onValueChange = { newValue -> viewModel.#{json_data['onTextChange']}(newValue) },", depth + 1)
-
38
elsif json_data['text'] && json_data['text'].match(/@\{([^}]+)\}/)
-
# Data binding update
-
3
variable = extract_variable_name(json_data['text'])
-
# Use a map update to notify the viewModel
-
3
code += "\n" + indent("onValueChange = { newValue -> viewModel.updateData(mapOf(\"#{variable}\" to newValue)) },", depth + 1)
-
else
-
35
code += "\n" + indent("onValueChange = { },", depth + 1)
-
end
-
-
# For CustomTextFieldWithMargins, we need to specify modifiers differently
-
39
if has_margins
-
# Box modifier with margins
-
2
box_modifiers = []
-
2
box_modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
2
if box_modifiers.any?
-
2
code += "\n" + indent("boxModifier = Modifier", depth + 1)
-
2
box_modifiers.each do |mod|
-
2
code += "\n" + indent(" #{mod}", depth + 1)
-
end
-
2
code += ","
-
end
-
-
# TextField modifier (size only, padding goes to contentPadding)
-
2
textfield_modifiers = []
-
2
textfield_modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
2
if textfield_modifiers.any?
-
code += "\n" + indent("textFieldModifier = Modifier", depth + 1)
-
textfield_modifiers.each do |mod|
-
code += "\n" + indent(" #{mod}", depth + 1)
-
end
-
code += ","
-
end
-
else
-
# Regular modifiers for CustomTextField (size and margins only, padding goes to contentPadding)
-
37
modifiers = []
-
37
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
37
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
37
if modifiers.any?
-
1
code += "\n" + indent("modifier = Modifier", depth + 1)
-
1
modifiers.each do |mod|
-
2
code += "\n" + indent(" #{mod}", depth + 1)
-
end
-
1
code += ","
-
end
-
end
-
-
# Add placeholder/hint with styling
-
# Always use Configuration.TextField.defaultPlaceholderColor if hintColor is not specified
-
39
if placeholder && placeholder != '""'
-
3
required_imports&.add(:configuration)
-
3
placeholder_code = "placeholder = { Text("
-
3
placeholder_code += "\n" + indent("text = #{placeholder}", depth + 2)
-
-
# Use hintColor if specified, otherwise use Configuration default
-
3
if json_data['hintColor']
-
1
hint_color = Helpers::ResourceResolver.process_color(json_data['hintColor'], required_imports)
-
1
placeholder_code += ",\n" + indent("color = #{hint_color}", depth + 2)
-
else
-
2
placeholder_code += ",\n" + indent("color = Configuration.TextField.defaultPlaceholderColor", depth + 2)
-
end
-
-
3
if json_data['hintFontSize']
-
1
placeholder_code += ",\n" + indent("fontSize = #{json_data['hintFontSize']}.sp", depth + 2)
-
end
-
-
3
if json_data['hintFont'] == 'bold'
-
1
placeholder_code += ",\n" + indent("fontWeight = FontWeight.Bold", depth + 2)
-
end
-
-
3
placeholder_code += "\n" + indent(") }", depth + 1)
-
3
code += "\n" + indent(placeholder_code, depth + 1) + ","
-
end
-
-
# Add visual transformation for secure fields
-
39
if is_secure
-
1
code += "\n" + indent("visualTransformation = PasswordVisualTransformation(),", depth + 1)
-
end
-
-
# Add custom TextField parameters
-
-
# Shape with corner radius
-
39
if json_data['cornerRadius']
-
1
required_imports&.add(:shape)
-
1
code += "\n" + indent("shape = RoundedCornerShape(#{json_data['cornerRadius']}.dp),", depth + 1)
-
end
-
-
# Content padding - internal padding within the text field
-
# Supports: paddings (array or single value), fieldPadding (legacy single value)
-
39
if json_data['paddings']
-
required_imports&.add(:padding_values)
-
paddings = json_data['paddings']
-
if paddings.is_a?(Array)
-
case paddings.length
-
when 1
-
code += "\n" + indent("contentPadding = PaddingValues(#{paddings[0]}.dp),", depth + 1)
-
when 2
-
# [vertical, horizontal]
-
code += "\n" + indent("contentPadding = PaddingValues(horizontal = #{paddings[1]}.dp, vertical = #{paddings[0]}.dp),", depth + 1)
-
when 4
-
# [top, right, bottom, left]
-
code += "\n" + indent("contentPadding = PaddingValues(start = #{paddings[3]}.dp, top = #{paddings[0]}.dp, end = #{paddings[1]}.dp, bottom = #{paddings[2]}.dp),", depth + 1)
-
end
-
else
-
code += "\n" + indent("contentPadding = PaddingValues(#{paddings}.dp),", depth + 1)
-
end
-
39
elsif json_data['fieldPadding']
-
required_imports&.add(:padding_values)
-
code += "\n" + indent("contentPadding = PaddingValues(#{json_data['fieldPadding']}.dp),", depth + 1)
-
end
-
-
# Text padding left - start padding for text content
-
39
if json_data['textPaddingLeft']
-
code += "\n" + indent("textPaddingStart = #{json_data['textPaddingLeft']}.dp,", depth + 1)
-
end
-
-
# Background colors
-
39
if json_data['background']
-
1
bg_color = Helpers::ResourceResolver.process_color(json_data['background'], required_imports)
-
1
code += "\n" + indent("backgroundColor = #{bg_color},", depth + 1)
-
end
-
-
39
if json_data['highlightBackground']
-
1
highlight_bg_color = Helpers::ResourceResolver.process_color(json_data['highlightBackground'], required_imports)
-
1
code += "\n" + indent("highlightBackgroundColor = #{highlight_bg_color},", depth + 1)
-
end
-
-
# Border color for outlined text fields
-
39
if json_data['borderColor']
-
1
border_color = Helpers::ResourceResolver.process_color(json_data['borderColor'], required_imports)
-
1
code += "\n" + indent("borderColor = #{border_color},", depth + 1)
-
end
-
-
# Border style handling
-
# borderStyle: none, line, bezel, roundedRect
-
39
if json_data['borderStyle']
-
case json_data['borderStyle'].downcase
-
when 'none'
-
code += "\n" + indent("isOutlined = false,", depth + 1)
-
when 'line', 'bezel', 'roundedrect'
-
code += "\n" + indent("isOutlined = true,", depth + 1)
-
end
-
# Set isOutlined and isSecure flags
-
# Automatically use outlined style if borderColor or borderWidth is specified
-
39
elsif json_data['outlined'] == true || json_data['borderColor'] || json_data['borderWidth']
-
2
code += "\n" + indent("isOutlined = true,", depth + 1)
-
end
-
-
39
if is_secure
-
1
code += "\n" + indent("isSecure = true,", depth + 1)
-
end
-
-
-
# Text styling - always add this last before closing
-
# Always include textStyle with at least a default color
-
39
required_imports&.add(:text_style)
-
39
style_parts = []
-
39
style_parts << "fontSize = #{json_data['fontSize']}.sp" if json_data['fontSize']
-
-
# Use fontColor if specified, otherwise default to black
-
39
if json_data['fontColor']
-
1
color_value = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
-
1
style_parts << "color = #{color_value}" if color_value
-
else
-
# Default to black text
-
38
default_color = Helpers::ResourceResolver.process_color('#000000', required_imports)
-
38
style_parts << "color = #{default_color}"
-
end
-
-
39
if json_data['textAlign']
-
3
required_imports&.add(:text_align)
-
3
case json_data['textAlign'].downcase
-
when 'center'
-
1
style_parts << "textAlign = TextAlign.Center"
-
when 'right'
-
1
style_parts << "textAlign = TextAlign.End"
-
when 'left'
-
1
style_parts << "textAlign = TextAlign.Start"
-
end
-
end
-
-
39
if style_parts.any?
-
# Remove trailing comma before adding textStyle
-
39
if code.end_with?(',')
-
39
code = code[0..-2]
-
end
-
39
code += ",\n" + indent("textStyle = TextStyle(#{style_parts.join(', ')})", depth + 1)
-
end
-
-
# Add focus/blur event handlers
-
39
if json_data['onFocus']
-
1
code += ",\n" + indent("onFocus = { viewModel.#{json_data['onFocus']}() }", depth + 1)
-
end
-
-
39
if json_data['onBlur']
-
1
code += ",\n" + indent("onBlur = { viewModel.#{json_data['onBlur']}() }", depth + 1)
-
end
-
-
39
if json_data['onBeginEditing']
-
1
code += ",\n" + indent("onBeginEditing = { viewModel.#{json_data['onBeginEditing']}() }", depth + 1)
-
end
-
-
39
if json_data['onEndEditing']
-
1
code += ",\n" + indent("onEndEditing = { viewModel.#{json_data['onEndEditing']}() }", depth + 1)
-
end
-
-
# Keyboard options (input, returnKeyType, contentType, autocapitalizationType, autocorrectionType)
-
39
keyboard_options = []
-
-
# Input type / contentType - contentType takes priority
-
39
if json_data['contentType']
-
required_imports&.add(:keyboard_type)
-
keyboard_type = case json_data['contentType'].downcase
-
when 'emailaddress', 'email'
-
'KeyboardType.Email'
-
when 'password', 'newpassword'
-
'KeyboardType.Password'
-
when 'telephonenumber', 'phone'
-
'KeyboardType.Phone'
-
when 'url'
-
'KeyboardType.Uri'
-
when 'creditcardnumber'
-
'KeyboardType.Number'
-
else
-
'KeyboardType.Text'
-
end
-
keyboard_options << "keyboardType = #{keyboard_type}"
-
39
elsif json_data['input']
-
6
required_imports&.add(:keyboard_type)
-
6
keyboard_type = case json_data['input']
-
when 'email'
-
1
'KeyboardType.Email'
-
when 'password'
-
1
'KeyboardType.Password'
-
when 'number'
-
1
'KeyboardType.Number'
-
when 'decimal'
-
1
'KeyboardType.Decimal'
-
when 'phone'
-
1
'KeyboardType.Phone'
-
else
-
1
'KeyboardType.Text'
-
end
-
6
keyboard_options << "keyboardType = #{keyboard_type}"
-
end
-
-
39
if json_data['returnKeyType']
-
6
required_imports&.add(:ime_action)
-
6
ime_action = case json_data['returnKeyType']
-
when 'Done'
-
1
'ImeAction.Done'
-
when 'Next'
-
1
'ImeAction.Next'
-
when 'Search'
-
1
'ImeAction.Search'
-
when 'Send'
-
1
'ImeAction.Send'
-
when 'Go'
-
1
'ImeAction.Go'
-
else
-
1
'ImeAction.Default'
-
end
-
6
keyboard_options << "imeAction = #{ime_action}"
-
end
-
-
# Auto-capitalization type
-
39
if json_data['autocapitalizationType']
-
required_imports&.add(:keyboard_capitalization)
-
capitalization = case json_data['autocapitalizationType'].downcase
-
when 'none'
-
'KeyboardCapitalization.None'
-
when 'words'
-
'KeyboardCapitalization.Words'
-
when 'sentences'
-
'KeyboardCapitalization.Sentences'
-
when 'allcharacters', 'characters'
-
'KeyboardCapitalization.Characters'
-
else
-
'KeyboardCapitalization.None'
-
end
-
keyboard_options << "capitalization = #{capitalization}"
-
end
-
-
# Auto-correction type
-
39
if json_data['autocorrectionType']
-
auto_correct = case json_data['autocorrectionType'].downcase
-
when 'no', 'false', 'off'
-
'false'
-
when 'yes', 'true', 'on', 'default'
-
'true'
-
else
-
'true'
-
end
-
keyboard_options << "autoCorrect = #{auto_correct}"
-
end
-
-
39
if keyboard_options.any?
-
12
code += ",\n" + indent("keyboardOptions = KeyboardOptions(#{keyboard_options.join(', ')})", depth + 1)
-
end
-
-
# Remove trailing comma and close
-
39
if code.end_with?(',')
-
code = code[0..-2]
-
end
-
-
39
code += "\n" + indent(")", depth)
-
-
39
code
-
end
-
-
1
private
-
-
1
def self.extract_variable_name(text)
-
6
if text && text.match(/@\{([^}]+)\}/)
-
5
$1.split('.').last
-
else
-
1
'value'
-
end
-
end
-
-
1
def self.indent(text, level)
-
242
return text if level == 0
-
163
spaces = ' ' * level
-
163
text.split("\n").map { |line|
-
174
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class TextViewComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
# TextView is multi-line text input (like TextArea)
-
# Uses 'text' for value and supports both 'hint' and 'placeholder' (hint is primary)
-
45
value = process_data_binding(json_data['text'] || '')
-
45
placeholder = json_data['hint'] || json_data['placeholder'] || ''
-
-
# Check if we need to wrap in Box for margins
-
45
has_margins = json_data['margins'] || json_data['topMargin'] || json_data['bottomMargin'] ||
-
json_data['leftMargin'] || json_data['rightMargin']
-
-
# Always use CustomTextField
-
45
required_imports&.add(:custom_textfield)
-
-
45
code = ""
-
45
if has_margins
-
11
required_imports&.add(:box)
-
11
code = indent("CustomTextFieldWithMargins(", depth)
-
else
-
34
code = indent("CustomTextField(", depth)
-
end
-
45
code += "\n" + indent("value = #{value},", depth + 1)
-
-
# onValueChange handler
-
45
if json_data['text'] && json_data['text'].match(/@\{([^}]+)\}/)
-
2
variable = extract_variable_name(json_data['text'])
-
2
code += "\n" + indent("onValueChange = { newValue -> viewModel.updateData(mapOf(\"#{variable}\" to newValue)) },", depth + 1)
-
43
elsif json_data['onTextChange']
-
1
code += "\n" + indent("onValueChange = { viewModel.#{json_data['onTextChange']}(it) },", depth + 1)
-
else
-
42
code += "\n" + indent("onValueChange = { },", depth + 1)
-
end
-
-
# For CustomTextFieldWithMargins, we need to specify modifiers differently
-
45
if has_margins
-
# Box modifier with margins
-
11
box_modifiers = []
-
11
box_modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
11
if box_modifiers.any?
-
11
code += "\n" + indent("boxModifier = Modifier", depth + 1)
-
11
box_modifiers.each do |mod|
-
11
code += "\n" + indent(" #{mod}", depth + 1)
-
end
-
11
code += ","
-
end
-
-
# TextField modifier
-
11
textfield_modifiers = []
-
# Size - default to fillMaxWidth for text areas
-
11
if json_data['width'] == 'matchParent' || !json_data['width']
-
10
textfield_modifiers << ".fillMaxWidth()"
-
else
-
1
textfield_modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
end
-
-
# Height for multi-line
-
11
if json_data['height']
-
3
if json_data['height'] == 'matchParent'
-
1
textfield_modifiers << ".fillMaxHeight()"
-
2
elsif json_data['height'] == 'wrapContent'
-
1
textfield_modifiers << ".wrapContentHeight()"
-
else
-
1
textfield_modifiers << ".height(#{json_data['height']}.dp)"
-
end
-
else
-
# Default height for text area
-
8
textfield_modifiers << ".height(120.dp)"
-
end
-
-
11
textfield_modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
-
11
if textfield_modifiers.any?
-
11
code += "\n" + indent("textFieldModifier = Modifier", depth + 1)
-
11
textfield_modifiers.each do |mod|
-
22
code += "\n" + indent(" #{mod}", depth + 1)
-
end
-
11
code += ","
-
end
-
else
-
# Regular modifiers for CustomTextField
-
34
modifiers = []
-
-
# Size - default to fillMaxWidth for text areas
-
34
if json_data['width'] == 'matchParent' || !json_data['width']
-
33
modifiers << ".fillMaxWidth()"
-
else
-
1
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
end
-
-
# Height for multi-line
-
34
if json_data['height']
-
3
if json_data['height'] == 'matchParent'
-
1
modifiers << ".fillMaxHeight()"
-
2
elsif json_data['height'] == 'wrapContent'
-
1
modifiers << ".wrapContentHeight()"
-
else
-
1
modifiers << ".height(#{json_data['height']}.dp)"
-
end
-
else
-
# Default height for text area
-
31
modifiers << ".height(120.dp)"
-
end
-
-
34
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
34
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
34
if modifiers.any?
-
34
code += "\n" + indent("modifier = Modifier", depth + 1)
-
34
modifiers.each do |mod|
-
68
code += "\n" + indent(" #{mod}", depth + 1)
-
end
-
34
code += ","
-
end
-
end
-
-
# Placeholder with optional line height styling
-
45
if placeholder && !placeholder.empty?
-
2
if json_data['hintLineHeightMultiple']
-
# Complex placeholder with line height
-
required_imports&.add(:text_style)
-
base_size = json_data['hintFontSize'] || json_data['fontSize'] || 14
-
line_height = base_size.to_f * json_data['hintLineHeightMultiple'].to_f
-
code += "\n" + indent("placeholder = {", depth + 1)
-
code += "\n" + indent("Text(", depth + 2)
-
code += "\n" + indent("text = #{quote(placeholder)},", depth + 3)
-
code += "\n" + indent("style = TextStyle(lineHeight = #{line_height}.sp)", depth + 3)
-
code += "\n" + indent(")", depth + 2)
-
code += "\n" + indent("},", depth + 1)
-
else
-
2
code += "\n" + indent("placeholder = { Text(#{quote(placeholder)}) },", depth + 1)
-
end
-
end
-
-
# Container inset - internal padding
-
45
if json_data['containerInset']
-
inset = json_data['containerInset']
-
if inset.is_a?(Array) && inset.length == 4
-
code += "\n" + indent("contentPadding = PaddingValues(top = #{inset[0]}.dp, end = #{inset[1]}.dp, bottom = #{inset[2]}.dp, start = #{inset[3]}.dp),", depth + 1)
-
elsif inset.is_a?(Numeric)
-
code += "\n" + indent("contentPadding = PaddingValues(#{inset}.dp),", depth + 1)
-
end
-
end
-
-
# Flexible height - auto-expand based on content
-
45
if json_data['flexible']
-
code += "\n" + indent("// flexible: true - height adjusts to content", depth + 1)
-
end
-
-
# Shape with corner radius
-
45
if json_data['cornerRadius']
-
1
required_imports&.add(:shape)
-
1
code += "\n" + indent("shape = RoundedCornerShape(#{json_data['cornerRadius']}.dp),", depth + 1)
-
end
-
-
# Background colors
-
45
if json_data['background']
-
1
bg_color = Helpers::ResourceResolver.process_color(json_data['background'], required_imports)
-
1
code += "\n" + indent("backgroundColor = #{bg_color},", depth + 1)
-
end
-
-
45
if json_data['highlightBackground']
-
1
highlight_color = Helpers::ResourceResolver.process_color(json_data['highlightBackground'], required_imports)
-
1
code += "\n" + indent("highlightBackgroundColor = #{highlight_color},", depth + 1)
-
end
-
-
# Border color for outlined text fields
-
45
if json_data['borderColor']
-
1
border_color = Helpers::ResourceResolver.process_color(json_data['borderColor'], required_imports)
-
1
code += "\n" + indent("borderColor = #{border_color},", depth + 1)
-
end
-
-
# Set isOutlined flag (TextView usually wants outlined style)
-
45
code += "\n" + indent("isOutlined = true,", depth + 1)
-
-
# Max lines for TextView
-
45
if json_data['maxLines']
-
1
code += "\n" + indent("maxLines = #{json_data['maxLines']},", depth + 1)
-
else
-
# Default to multiple lines
-
44
code += "\n" + indent("maxLines = Int.MAX_VALUE,", depth + 1)
-
end
-
-
# Single line false for multi-line
-
45
code += "\n" + indent("singleLine = false,", depth + 1)
-
-
# Line break mode (overflow handling)
-
45
if json_data['lineBreakMode']
-
# Note: For multi-line TextField, overflow is less relevant
-
# but we include it for completeness
-
case json_data['lineBreakMode'].to_s.downcase
-
when 'clip'
-
code += "\n" + indent("// lineBreakMode: clip", depth + 1)
-
when 'tail', 'truncatetail'
-
code += "\n" + indent("// lineBreakMode: truncate tail", depth + 1)
-
when 'head', 'truncatehead'
-
code += "\n" + indent("// lineBreakMode: truncate head", depth + 1)
-
when 'middle', 'truncatemiddle'
-
code += "\n" + indent("// lineBreakMode: truncate middle", depth + 1)
-
when 'wordwrap', 'word'
-
code += "\n" + indent("// lineBreakMode: word wrap (default)", depth + 1)
-
when 'charwrap', 'char'
-
code += "\n" + indent("// lineBreakMode: character wrap", depth + 1)
-
end
-
end
-
-
# Text styling
-
45
if json_data['fontSize'] || json_data['fontColor']
-
3
required_imports&.add(:text_style)
-
3
style_parts = []
-
3
style_parts << "fontSize = #{json_data['fontSize']}.sp" if json_data['fontSize']
-
3
if json_data['fontColor']
-
2
font_color = Helpers::ResourceResolver.process_color(json_data['fontColor'], required_imports)
-
2
style_parts << "color = #{font_color}"
-
end
-
-
3
if style_parts.any?
-
3
code += "\n" + indent("textStyle = TextStyle(#{style_parts.join(', ')})", depth + 1)
-
end
-
end
-
-
# Keyboard options
-
45
keyboard_options = []
-
-
# keyboardType
-
45
if json_data['keyboardType'] || json_data['input']
-
required_imports&.add(:keyboard_type)
-
input_type = json_data['keyboardType'] || json_data['input']
-
keyboard_type = case input_type.to_s.downcase
-
when 'email'
-
'KeyboardType.Email'
-
when 'number'
-
'KeyboardType.Number'
-
when 'decimal'
-
'KeyboardType.Decimal'
-
when 'phone'
-
'KeyboardType.Phone'
-
when 'url'
-
'KeyboardType.Uri'
-
else
-
'KeyboardType.Text'
-
end
-
keyboard_options << "keyboardType = #{keyboard_type}"
-
end
-
-
45
if json_data['returnKeyType']
-
4
required_imports&.add(:ime_action)
-
4
ime_action = case json_data['returnKeyType']
-
when 'Done'
-
1
'ImeAction.Done'
-
when 'Next'
-
1
'ImeAction.Next'
-
when 'Default'
-
1
'ImeAction.Default'
-
else
-
1
'ImeAction.Default'
-
end
-
4
keyboard_options << "imeAction = #{ime_action}"
-
end
-
-
45
if keyboard_options.any?
-
4
code += ",\n" + indent("keyboardOptions = KeyboardOptions(#{keyboard_options.join(', ')})", depth + 1)
-
end
-
-
# scrollEnabled - controls vertical scroll within TextView
-
45
if json_data.key?('scrollEnabled')
-
# In Compose, scrolling is controlled via verticalScroll modifier
-
# For TextField, we just note it - actual implementation may need custom handling
-
if json_data['scrollEnabled'] == false
-
code += ",\n" + indent("// scrollEnabled = false - scrolling disabled", depth + 1)
-
end
-
end
-
-
# hideOnFocused - hide placeholder when focused
-
# Note: Compose TextField hides placeholder by default when there's text
-
# This is primarily for when you want different behavior
-
45
if json_data.key?('hideOnFocused')
-
code += ",\n" + indent("// hideOnFocused = #{json_data['hideOnFocused']}", depth + 1)
-
end
-
-
# Enabled state
-
45
if json_data.key?('enabled')
-
3
if json_data['enabled'].is_a?(String) && json_data['enabled'].start_with?('@{')
-
1
variable = json_data['enabled'].match(/@\{([^}]+)\}/)[1]
-
1
code += ",\n" + indent("enabled = data.#{variable}", depth + 1)
-
else
-
2
code += ",\n" + indent("enabled = #{json_data['enabled']}", depth + 1)
-
end
-
end
-
-
# Remove trailing comma and close
-
45
if code.end_with?(',')
-
35
code = code[0..-2]
-
end
-
-
45
code += "\n" + indent(")", depth)
-
45
code
-
end
-
-
1
private
-
-
1
def self.process_data_binding(text)
-
49
return quote(text) unless text.is_a?(String)
-
-
49
if text.match(/@\{([^}]+)\}/)
-
4
variable = $1
-
4
if variable.include?(' ?? ')
-
2
parts = variable.split(' ?? ')
-
2
var_name = parts[0].strip
-
2
"data.#{var_name}"
-
else
-
2
"data.#{variable}"
-
end
-
else
-
45
quote(text)
-
end
-
end
-
-
1
def self.extract_variable_name(text)
-
5
if text && text.match(/@\{([^}]+)\}/)
-
3
$1.split('.').last
-
else
-
2
'value'
-
end
-
end
-
-
1
def self.quote(text)
-
# Escape special characters properly
-
52
escaped = text.gsub('\\', '\\\\\\\\') # Escape backslashes first
-
.gsub('"', '\\"') # Escape quotes
-
.gsub("\n", '\\n') # Escape newlines
-
.gsub("\r", '\\r') # Escape carriage returns
-
.gsub("\t", '\\t') # Escape tabs
-
52
"\"#{escaped}\""
-
end
-
-
1
def self.indent(text, level)
-
493
return text if level == 0
-
402
spaces = ' ' * level
-
402
text.split("\n").map { |line|
-
405
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class ToggleComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
# Toggle in iOS maps to Switch in Android
-
13
code = indent("Switch(", depth)
-
-
# Checked state
-
13
checked_value = if json_data['data']
-
1
"@{#{json_data['data']}}"
-
12
elsif json_data['isOn']
-
1
json_data['isOn'].to_s
-
else
-
11
'false'
-
end
-
-
# Process data binding
-
13
if checked_value.start_with?('@{')
-
1
variable = checked_value[2..-2]
-
1
code += "\n" + indent("checked = data.#{variable},", depth + 1)
-
1
code += "\n" + indent("onCheckedChange = { newValue -> viewModel.updateData(mapOf(\"#{variable}\" to newValue)) },", depth + 1)
-
else
-
12
code += "\n" + indent("checked = #{checked_value},", depth + 1)
-
-
# Handle onclick (lowercase) -> selector format only
-
# onClick (camelCase) -> binding format only
-
12
if json_data['onclick']
-
1
handler_call = Helpers::ModifierBuilder.get_event_handler_call(json_data['onclick'], is_camel_case: false)
-
1
code += "\n" + indent("onCheckedChange = { #{handler_call} },", depth + 1)
-
11
elsif json_data['onClick']
-
handler_call = Helpers::ModifierBuilder.get_event_handler_call(json_data['onClick'], is_camel_case: true)
-
code += "\n" + indent("onCheckedChange = { #{handler_call} },", depth + 1)
-
else
-
11
code += "\n" + indent("onCheckedChange = { },", depth + 1)
-
end
-
end
-
-
# Build modifiers
-
13
modifiers = []
-
13
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
13
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
13
modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
-
-
# Add weight modifier if in Row or Column
-
13
if parent_type == 'Row' || parent_type == 'Column'
-
2
modifiers.concat(Helpers::ModifierBuilder.build_weight(json_data, parent_type))
-
end
-
-
13
code += Helpers::ModifierBuilder.format(modifiers, depth)
-
-
# Colors if specified
-
13
if json_data['tintColor'] || json_data['backgroundColor']
-
3
required_imports&.add(:switch_colors)
-
3
code += ",\n" + indent("colors = SwitchDefaults.colors(", depth + 1)
-
-
3
if json_data['tintColor']
-
2
checkedthumbcolor_resolved = Helpers::ResourceResolver.process_color(json_data['tintColor'], required_imports)
-
2
code += "\n" + indent("checkedThumbColor = #{checkedthumbcolor_resolved},", depth + 2)
-
2
checkedtrackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['tintColor'], required_imports)
-
2
code += "\n" + indent("checkedTrackColor = #{checkedtrackcolor_resolved}.copy(alpha = 0.5f)", depth + 2)
-
end
-
-
3
if json_data['backgroundColor']
-
2
code += ",\n" if json_data['tintColor']
-
2
uncheckedtrackcolor_resolved = Helpers::ResourceResolver.process_color(json_data['backgroundColor'], required_imports)
-
2
code += "\n" + indent("uncheckedTrackColor = #{uncheckedtrackcolor_resolved}", depth + 2)
-
end
-
-
3
code += "\n" + indent(")", depth + 1)
-
end
-
-
13
code += "\n" + indent(")", depth)
-
13
code
-
end
-
-
1
private
-
-
1
def self.indent(text, level)
-
67
return text if level == 0
-
40
spaces = ' ' * level
-
40
text.split("\n").map { |line|
-
42
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class WebComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
19
required_imports&.add(:webview)
-
-
# Web uses 'url' for the web page URL
-
19
url = if json_data['url'] && json_data['url'].match(/@\{([^}]+)\}/)
-
2
variable = $1
-
2
"data.#{variable}"
-
17
elsif json_data['url']
-
2
"\"#{json_data['url']}\""
-
else
-
15
'""'
-
end
-
-
# Generate WebView using AndroidView
-
19
code = indent("AndroidView(", depth)
-
19
code += "\n" + indent("factory = { context ->", depth + 1)
-
19
code += "\n" + indent("WebView(context).apply {", depth + 2)
-
-
# WebView settings
-
19
code += "\n" + indent("settings.javaScriptEnabled = #{json_data['javaScriptEnabled'] != false}", depth + 3)
-
-
19
if json_data['userAgent']
-
1
code += "\n" + indent("settings.userAgentString = \"#{json_data['userAgent']}\"", depth + 3)
-
end
-
-
19
if json_data['allowZoom']
-
1
code += "\n" + indent("settings.builtInZoomControls = true", depth + 3)
-
1
code += "\n" + indent("settings.displayZoomControls = false", depth + 3)
-
end
-
-
# Load URL
-
19
code += "\n" + indent("loadUrl(#{url})", depth + 3)
-
-
# WebViewClient for handling navigation
-
19
code += "\n" + indent("webViewClient = WebViewClient()", depth + 3)
-
-
# WebChromeClient for JavaScript alerts
-
19
if json_data['javaScriptEnabled'] != false
-
17
code += "\n" + indent("webChromeClient = WebChromeClient()", depth + 3)
-
end
-
-
19
code += "\n" + indent("}", depth + 2)
-
19
code += "\n" + indent("},", depth + 1)
-
-
# Update callback to handle URL changes
-
19
code += "\n" + indent("update = { webView ->", depth + 1)
-
-
19
if json_data['url'] && json_data['url'].match(/@\{([^}]+)\}/)
-
2
code += "\n" + indent("webView.loadUrl(#{url})", depth + 2)
-
end
-
-
19
code += "\n" + indent("},", depth + 1)
-
-
# Build modifiers
-
19
modifiers = []
-
-
# Default size for WebView
-
19
if !json_data['width'] && !json_data['height']
-
18
modifiers << ".fillMaxSize()"
-
else
-
1
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
end
-
-
19
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
19
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
-
# Border for WebView
-
19
if json_data['borderWidth'] && json_data['borderColor']
-
1
required_imports&.add(:border)
-
1
modifiers << ".border(#{json_data['borderWidth']}.dp, Helpers::ResourceResolver.process_color('#{json_data['borderColor']}', required_imports))"
-
end
-
-
19
code += Helpers::ModifierBuilder.format(modifiers, depth)
-
-
19
code += "\n" + indent(")", depth)
-
19
code
-
end
-
-
1
private
-
-
1
def self.indent(text, level)
-
236
return text if level == 0
-
197
spaces = ' ' * level
-
197
text.split("\n").map { |line|
-
200
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative '../helpers/modifier_builder'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Components
-
1
class WebviewComponent
-
1
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
7
required_imports&.add(:webview)
-
-
# WebView uses 'url' for the web page URL
-
7
url = if json_data['url'] && json_data['url'].match(/@\{([^}]+)\}/)
-
1
variable = $1
-
1
"data.#{variable}"
-
6
elsif json_data['url']
-
1
"\"#{json_data['url']}\""
-
else
-
5
'""'
-
end
-
-
# Generate WebView using AndroidView
-
7
code = indent("AndroidView(", depth)
-
7
code += "\n" + indent("factory = { context ->", depth + 1)
-
7
code += "\n" + indent("WebView(context).apply {", depth + 2)
-
-
# WebView settings
-
7
code += "\n" + indent("settings.javaScriptEnabled = #{json_data['javaScriptEnabled'] != false}", depth + 3)
-
-
7
if json_data['userAgent']
-
1
code += "\n" + indent("settings.userAgentString = \"#{json_data['userAgent']}\"", depth + 3)
-
end
-
-
7
code += "\n" + indent("webViewClient = WebViewClient()", depth + 3)
-
7
code += "\n" + indent("webChromeClient = WebChromeClient()", depth + 3)
-
-
# Load URL
-
7
code += "\n" + indent("loadUrl(#{url})", depth + 3)
-
-
7
code += "\n" + indent("}", depth + 2)
-
7
code += "\n" + indent("},", depth + 1)
-
-
# Build modifiers
-
7
modifiers = []
-
7
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
7
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
7
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
7
modifiers.concat(Helpers::ModifierBuilder.build_alignment(json_data, required_imports, parent_type))
-
-
7
if json_data['cornerRadius']
-
1
required_imports&.add(:shape)
-
1
modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
-
end
-
-
7
code += Helpers::ModifierBuilder.format(modifiers, depth)
-
-
7
code += "\n" + indent(")", depth)
-
7
code
-
end
-
-
1
private
-
-
1
def self.indent(text, level)
-
71
return text if level == 0
-
57
spaces = ' ' * level
-
57
text.split("\n").map { |line|
-
57
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'json'
-
1
require 'fileutils'
-
1
require 'set'
-
1
require_relative '../core/config_manager'
-
1
require_relative '../core/project_finder'
-
1
require_relative '../core/logger'
-
1
require_relative 'style_loader'
-
1
require_relative 'data_model_updater'
-
1
require_relative 'helpers/import_manager'
-
1
require_relative 'helpers/modifier_builder'
-
1
require_relative 'components/text_component'
-
1
require_relative 'components/button_component'
-
1
require_relative 'components/textfield_component'
-
1
require_relative 'components/container_component'
-
1
require_relative 'components/image_component'
-
1
require_relative 'components/scrollview_component'
-
1
require_relative 'components/switch_component'
-
1
require_relative 'components/slider_component'
-
1
require_relative 'components/progress_component'
-
1
require_relative 'components/selectbox_component'
-
1
require_relative 'components/checkbox_component'
-
1
require_relative 'components/radio_component'
-
1
require_relative 'components/segment_component'
-
1
require_relative 'components/networkimage_component'
-
1
require_relative 'components/circleimage_component'
-
1
require_relative 'components/indicator_component'
-
1
require_relative 'components/textview_component'
-
1
require_relative 'components/collection_component'
-
1
require_relative 'components/table_component'
-
1
require_relative 'components/web_component'
-
1
require_relative 'components/gradientview_component'
-
1
require_relative 'components/blurview_component'
-
-
1
module KjuiTools
-
1
module Compose
-
# Refactored ComposeBuilder - under 300 lines
-
1
class ComposeBuilder
-
1
def initialize
-
69
@config = Core::ConfigManager.load_config
-
69
@source_path = Core::ProjectFinder.get_full_source_path || Dir.pwd
-
69
source_directory = @config['source_directory'] || 'src/main'
-
69
@layouts_dir = File.join(@source_path, source_directory, @config['layouts_directory'] || 'assets/Layouts')
-
69
@view_dir = File.join(@source_path, source_directory, @config['view_directory'] || 'kotlin/views')
-
69
@package_name = @config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.app'
-
-
69
FileUtils.mkdir_p(@view_dir) unless File.exist?(@view_dir)
-
end
-
-
1
def build(options = {})
-
# Get all JSON files but exclude Resources folder
-
2
json_files = Dir.glob(File.join(@layouts_dir, '**/*.json')).reject do |file|
-
1
file.include?('/Resources/')
-
end
-
-
2
if json_files.empty?
-
2
Core::Logger.warn "No JSON files found in #{@layouts_dir}"
-
2
return
-
end
-
-
# Update data models first
-
data_updater = DataModelUpdater.new
-
data_updater.update_data_models
-
-
# Build each JSON file
-
json_files.each { |file| build_file(file) }
-
end
-
-
1
def build_file(json_file)
-
5
base_name = File.basename(json_file, '.json')
-
5
snake_case_name = to_snake_case(base_name)
-
5
pascal_case_name = to_pascal_case(base_name)
-
-
begin
-
5
json_content = File.read(json_file)
-
5
json_data = JSON.parse(json_content)
-
4
json_data = StyleLoader.load_and_merge(json_data)
-
-
4
@required_imports = Set.new
-
4
@included_views = Set.new
-
4
@cell_views = Set.new
-
4
@custom_components = Set.new
-
-
# Find the GeneratedView file
-
4
generated_view_file = File.join(@view_dir, snake_case_name, "#{pascal_case_name}GeneratedView.kt")
-
-
4
if File.exist?(generated_view_file)
-
update_generated_file(generated_view_file, json_data)
-
else
-
4
Core::Logger.warn "GeneratedView file not found: #{generated_view_file}"
-
end
-
-
1
rescue JSON::ParserError => e
-
1
Core::Logger.error "Failed to parse #{json_file}: #{e.message}"
-
rescue => e
-
Core::Logger.error "Failed to process #{json_file}: #{e.message}"
-
end
-
end
-
-
1
private
-
-
1
def generate_component(json_data, depth = 0, parent_type = nil)
-
34
return "" unless json_data.is_a?(Hash)
-
-
32
component_type = json_data['type'] || 'View'
-
-
# Handle includes
-
32
return generate_include(json_data, depth) if json_data['include']
-
-
# Generate component based on type
-
32
case component_type
-
when 'ScrollView', 'Scroll'
-
2
result = Components::ScrollViewComponent.generate(json_data, depth, @required_imports, parent_type)
-
2
handle_container_result(result, depth, parent_type)
-
when 'SafeAreaView'
-
generate_safe_area_view(json_data, depth)
-
when 'View'
-
1
result = Components::ContainerComponent.generate(json_data, depth, @required_imports, parent_type)
-
1
handle_container_result(result, depth, parent_type)
-
when 'Text', 'Label'
-
5
Components::TextComponent.generate(json_data, depth, @required_imports, parent_type)
-
when 'Button'
-
1
Components::ButtonComponent.generate(json_data, depth, @required_imports, parent_type)
-
when 'Image'
-
1
Components::ImageComponent.generate(json_data, depth, @required_imports, parent_type)
-
when 'TextField'
-
1
Components::TextFieldComponent.generate(json_data, depth, @required_imports, parent_type)
-
when 'Switch', 'Toggle'
-
2
Components::SwitchComponent.generate(json_data, depth, @required_imports, parent_type)
-
when 'Slider'
-
1
Components::SliderComponent.generate(json_data, depth, @required_imports, parent_type)
-
when 'Progress'
-
1
Components::ProgressComponent.generate(json_data, depth, @required_imports, parent_type)
-
when 'SelectBox'
-
1
Components::SelectBoxComponent.generate(json_data, depth, @required_imports, parent_type)
-
when 'Check', 'Checkbox', 'CheckBox'
-
2
Components::CheckboxComponent.generate(json_data, depth, @required_imports, parent_type)
-
when 'Radio'
-
1
Components::RadioComponent.generate(json_data, depth, @required_imports, parent_type)
-
when 'Segment'
-
1
Components::SegmentComponent.generate(json_data, depth, @required_imports, parent_type)
-
when 'NetworkImage'
-
1
Components::NetworkImageComponent.generate(json_data, depth, @required_imports, parent_type)
-
when 'CircleImage'
-
1
Components::CircleImageComponent.generate(json_data, depth, @required_imports, parent_type)
-
when 'Indicator'
-
1
Components::IndicatorComponent.generate(json_data, depth, @required_imports, parent_type)
-
when 'TextView'
-
1
Components::TextViewComponent.generate(json_data, depth, @required_imports, parent_type)
-
when 'Collection'
-
# Extract cell classes for imports
-
1
cell_classes = json_data['cellClasses'] || []
-
1
cell_classes.each do |cell_class|
-
1
@cell_views&.add(cell_class)
-
end
-
1
Components::CollectionComponent.generate(json_data, depth, @required_imports, parent_type)
-
when 'Table'
-
1
Components::TableComponent.generate(json_data, depth, @required_imports, parent_type)
-
when 'Web'
-
1
Components::WebComponent.generate(json_data, depth, @required_imports, parent_type)
-
when 'GradientView'
-
1
result = Components::GradientviewComponent.generate(json_data, depth, @required_imports, parent_type)
-
1
handle_container_result(result, depth, parent_type)
-
when 'BlurView'
-
1
result = Components::BlurviewComponent.generate(json_data, depth, @required_imports, parent_type)
-
1
handle_container_result(result, depth, parent_type)
-
when 'Spacer'
-
2
"Spacer(modifier = Modifier.height(#{json_data['height'] || 8}.dp))"
-
else
-
# Check for custom components
-
1
check_custom_component(component_type, json_data, depth, parent_type)
-
end
-
end
-
-
1
def check_custom_component(component_type, json_data, depth, parent_type)
-
# Try to load custom component mappings if they exist
-
1
mappings_file = File.join(File.dirname(__FILE__), 'components', 'extensions', 'component_mappings.rb')
-
-
1
if File.exist?(mappings_file)
-
require_relative 'components/extensions/component_mappings'
-
-
if defined?(Components::Extensions::COMPONENT_MAPPINGS)
-
component_class = Components::Extensions::COMPONENT_MAPPINGS[component_type]
-
-
if component_class
-
# Load the custom component file
-
snake_case_name = component_type.gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
-
.gsub(/([a-z\d])([A-Z])/,'\1_\2')
-
.downcase
-
component_file = File.join(File.dirname(__FILE__), 'components', 'extensions', "#{snake_case_name}_component.rb")
-
-
if File.exist?(component_file)
-
require_relative "components/extensions/#{snake_case_name}_component"
-
-
# Add import for the custom component
-
@custom_components&.add(component_type)
-
-
result = component_class.generate(json_data, depth, @required_imports, parent_type)
-
-
# Handle container components that return metadata
-
if result.is_a?(Hash) && result[:children]
-
return handle_container_result(result, depth, parent_type)
-
else
-
return result
-
end
-
end
-
end
-
end
-
end
-
-
1
"// TODO: Implement component type: #{component_type}"
-
end
-
-
1
def handle_container_result(result, depth, parent_type = nil)
-
7
if result.is_a?(Hash)
-
6
code = result[:code]
-
6
children = result[:children] || []
-
6
layout_type = result[:layout_type] || parent_type
-
6
json_data = result[:json_data]
-
-
# Add lifecycle effects at the start of container content
-
6
if json_data && Helpers::ModifierBuilder.has_lifecycle_events?(json_data)
-
lifecycle = Helpers::ModifierBuilder.build_lifecycle_effects(json_data, depth + 1, @required_imports)
-
code += "\n" + lifecycle[:before] unless lifecycle[:before].empty?
-
end
-
-
6
children.each do |child|
-
1
child_code = generate_component(child, depth + 1, layout_type)
-
1
code += "\n" + child_code unless child_code.empty?
-
end
-
-
6
code += result[:closing] if result[:closing]
-
6
code
-
else
-
1
result
-
end
-
end
-
-
1
def generate_safe_area_view(json_data, depth)
-
# Parse edges - support both 'edges' and 'safeAreaInsetPositions' (alias)
-
3
edges_array = json_data['edges'] || json_data['safeAreaInsetPositions'] || ['all']
-
3
edges = edges_array.is_a?(Array) ? edges_array : [edges_array]
-
-
# Parse orientation for child layout
-
3
orientation = json_data['orientation']
-
-
# Determine container type based on orientation
-
# No orientation = Box (like ZStack in SwiftUI)
-
3
container = case orientation
-
when 'horizontal' then 'Row'
-
when 'vertical' then 'Column'
-
3
else 'Box'
-
end
-
3
code = indent("#{container}(", depth)
-
-
# Build modifiers
-
3
modifiers = ["Modifier"]
-
3
modifiers << ".fillMaxWidth()"
-
-
# Apply safe area padding based on edges
-
3
if edges.include?('all')
-
3
modifiers << ".systemBarsPadding()"
-
else
-
modifiers << ".statusBarsPadding()" if edges.include?('top')
-
modifiers << ".navigationBarsPadding()" if edges.include?('bottom')
-
# For start/end, use systemBarsPadding
-
modifiers << ".systemBarsPadding()" if edges.include?('start') || edges.include?('end')
-
end
-
-
# Check if keyboard padding should be applied
-
3
ignore_keyboard = json_data['ignoreKeyboard'] == true
-
3
modifiers << ".imePadding()" unless ignore_keyboard
-
-
3
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
3
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
3
modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, @required_imports))
-
-
3
code += Helpers::ModifierBuilder.format(modifiers, depth)
-
3
code += "\n" + indent(") {", depth)
-
-
# Get children - support both 'child' and 'children'
-
3
children = json_data['children'] || json_data['child'] || []
-
3
children = [children] unless children.is_a?(Array)
-
-
3
children.each do |child|
-
2
child_code = generate_component(child, depth + 1)
-
2
code += "\n" + child_code unless child_code.empty?
-
end
-
-
3
code += "\n" + indent("}", depth)
-
3
code
-
end
-
-
1
def generate_include(json_data, depth)
-
5
include_name = json_data['include']
-
5
pascal_name = to_pascal_case(include_name)
-
5
snake_name = to_snake_case(include_name)
-
-
# Check if we should use DynamicView
-
5
use_dynamic = json_data['dynamic'] == true
-
-
# Track this included view for imports
-
5
@included_views&.add(snake_name) unless use_dynamic
-
-
# Track required imports for LaunchedEffect if we have data bindings
-
5
has_data_bindings = false
-
-
# Check if there's data or shared_data to pass
-
5
include_data = json_data['data'] || {}
-
5
shared_data = json_data['shared_data'] || {}
-
-
# Check for @{} bindings in data
-
5
include_data.each do |key, value|
-
1
if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
-
1
has_data_bindings = true
-
1
unless use_dynamic
-
1
@required_imports.add(:LaunchedEffect)
-
1
@required_imports.add(:remember)
-
end
-
1
break
-
end
-
end
-
-
# If using dynamic view, generate DynamicView call
-
5
if use_dynamic
-
1
return generate_dynamic_include(json_data, depth, include_data, shared_data, has_data_bindings)
-
end
-
-
# Generate unique instance ID for this include
-
4
instance_id = "#{to_camel_case(include_name)}Instance#{depth}"
-
-
4
code = ""
-
-
# Create a remember block for the ViewModel instance
-
4
code += indent("val context = LocalContext.current", depth)
-
4
code += "\n"
-
4
code += indent("val #{instance_id} = remember { #{pascal_name}ViewModel(context.applicationContext as Application) }", depth)
-
4
code += "\n"
-
-
# If we have data bindings, add LaunchedEffect to update on parent data changes
-
4
if has_data_bindings || shared_data.any?
-
2
code += "\n" + indent("// Update included view when parent data changes", depth)
-
2
code += "\n" + indent("LaunchedEffect(", depth)
-
-
# Add keys for all bound variables
-
2
keys = []
-
2
include_data.each do |key, value|
-
1
if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
-
1
variable = $1
-
1
keys << "data.#{variable}"
-
end
-
end
-
2
shared_data.each do |key, value|
-
1
if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
-
1
variable = $1
-
1
keys << "data.#{variable}"
-
end
-
end
-
-
2
if keys.any?
-
2
code += keys.join(", ")
-
else
-
code += "Unit"
-
end
-
-
2
code += ") {"
-
2
code += "\n" + indent("val updates = mutableMapOf<String, Any>()", depth + 1)
-
-
# Process data (one-way binding from parent to child)
-
2
include_data.each do |key, value|
-
1
if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
-
# This is a data binding reference to parent data
-
1
variable = $1
-
1
code += "\n" + indent("updates[\"#{key}\"] = data.#{variable}", depth + 1)
-
else
-
# This is a static value
-
formatted_value = format_value_for_kotlin(value)
-
code += "\n" + indent("updates[\"#{key}\"] = #{formatted_value}", depth + 1)
-
end
-
end
-
-
# Process shared_data (two-way binding)
-
2
if shared_data.any?
-
1
code += "\n" + indent("// Shared data for two-way binding", depth + 1)
-
1
shared_data.each do |key, value|
-
1
if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
-
# This creates a two-way binding
-
1
variable = $1
-
1
code += "\n" + indent("updates[\"#{key}\"] = data.#{variable}", depth + 1)
-
# TODO: Also need to update parent when child changes
-
else
-
# Static value for shared_data
-
formatted_value = format_value_for_kotlin(value)
-
code += "\n" + indent("updates[\"#{key}\"] = #{formatted_value}", depth + 1)
-
end
-
end
-
end
-
-
2
code += "\n" + indent("#{instance_id}.updateData(updates)", depth + 1)
-
2
code += "\n" + indent("}", depth)
-
end
-
-
# Generate the included view call
-
4
code += "\n" + indent("#{pascal_name}View(", depth)
-
4
code += "\n" + indent("viewModel = #{instance_id}", depth + 1)
-
4
code += "\n" + indent(")", depth)
-
-
4
code
-
end
-
-
1
def generate_dynamic_include(json_data, depth, include_data, shared_data, has_data_bindings)
-
1
include_name = json_data['include']
-
-
# Add required imports for SafeDynamicView
-
1
@required_imports.add(:safe_dynamic_view)
-
-
1
code = ""
-
-
# Build data map from bindings and current data
-
1
code += indent("// Build data map with bindings", depth)
-
1
code += "\n" + indent("val dynamicData = mutableMapOf<String, Any>()", depth)
-
-
# Add all current data values
-
1
code += "\n" + indent("// Add current data values", depth)
-
1
code += "\n" + indent("data.forEach { (key, value) ->", depth)
-
1
code += "\n" + indent("dynamicData[key] = value", depth + 1)
-
1
code += "\n" + indent("}", depth)
-
-
# Process include_data bindings
-
1
if include_data.any?
-
code += "\n" + indent("// Process include data bindings", depth)
-
include_data.each do |key, value|
-
if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
-
# This is a data binding reference to parent data
-
variable = $1
-
code += "\n" + indent("data[\"#{variable}\"]?.let { dynamicData[\"#{key}\"] = it }", depth)
-
else
-
# This is a static value
-
formatted_value = format_value_for_kotlin(value)
-
code += "\n" + indent("dynamicData[\"#{key}\"] = #{formatted_value}", depth)
-
end
-
end
-
end
-
-
# Process shared_data bindings
-
1
if shared_data.any?
-
code += "\n" + indent("// Process shared data bindings", depth)
-
shared_data.each do |key, value|
-
if value.is_a?(String) && value.match(/@\{([^}]+)\}/)
-
# This creates a two-way binding
-
variable = $1
-
code += "\n" + indent("data[\"#{variable}\"]?.let { dynamicData[\"#{key}\"] = it }", depth)
-
else
-
# Static value for shared_data
-
formatted_value = format_value_for_kotlin(value)
-
code += "\n" + indent("dynamicData[\"#{key}\"] = #{formatted_value}", depth)
-
end
-
end
-
end
-
-
# Add all viewModel methods as functions to the data map
-
1
code += "\n" + indent("// Add viewModel methods as event handlers", depth)
-
1
code += "\n" + indent("// Note: Add specific method references as needed", depth)
-
1
code += "\n" + indent("// Example: dynamicData[\"onButtonClick\"] = { viewModel.onButtonClick() }", depth)
-
-
# Call SafeDynamicView
-
1
code += "\n" + indent("// Render dynamic view", depth)
-
1
code += "\n" + indent("SafeDynamicView(", depth)
-
1
code += "\n" + indent("layoutName = \"#{include_name}\",", depth + 1)
-
1
code += "\n" + indent("data = dynamicData", depth + 1)
-
1
code += "\n" + indent(")", depth)
-
-
1
code
-
end
-
-
1
def format_value_for_kotlin(value)
-
7
case value
-
when String
-
1
"\"#{value.gsub('"', '\\"')}\""
-
when Integer
-
1
value.to_s
-
when Float
-
1
"#{value}f"
-
when TrueClass, FalseClass
-
2
value.to_s
-
when nil
-
1
"null"
-
else
-
1
"\"#{value}\""
-
end
-
end
-
-
1
def update_generated_file(file_path, json_data)
-
existing_content = File.read(file_path)
-
-
if existing_content.include?('// >>> GENERATED_CODE_START') &&
-
existing_content.include?('// >>> GENERATED_CODE_END')
-
-
# Extract the layout name from file path
-
layout_name = File.basename(File.dirname(file_path))
-
-
# Generate both static and dynamic versions
-
static_content = generate_component(json_data, 1)
-
dynamic_content = generate_dynamic_view_content(layout_name, json_data, 1)
-
-
# Create content that switches based on DynamicModeManager
-
composable_content = generate_mode_aware_content(layout_name, static_content, dynamic_content, 1)
-
-
updated_content = existing_content.gsub(
-
/\/\/ >>> GENERATED_CODE_START.*?\/\/ >>> GENERATED_CODE_END/m,
-
"// >>> GENERATED_CODE_START\n#{composable_content} // >>> GENERATED_CODE_END"
-
)
-
-
updated_content = update_imports(updated_content)
-
File.write(file_path, updated_content)
-
Core::Logger.success "Updated: #{file_path}"
-
else
-
Core::Logger.warn "Generated code markers not found in #{file_path}"
-
end
-
end
-
-
1
def generate_mode_aware_content(layout_name, static_content, dynamic_content, depth)
-
indent_str = " " * depth
-
-
code = ""
-
code += "#{indent_str}// Check if Dynamic Mode is active\n"
-
code += "#{indent_str}if (DynamicModeManager.isActive()) {\n"
-
code += "#{indent_str} // Dynamic Mode - use SafeDynamicView for real-time updates\n"
-
code += dynamic_content
-
code += "#{indent_str}} else {\n"
-
code += "#{indent_str} // Static Mode - use generated code\n"
-
code += " #{static_content}"
-
code += "#{indent_str}}\n"
-
-
# Add required imports for DynamicModeManager
-
@required_imports.add(:dynamic_mode_manager)
-
# SafeDynamicView import is already added in generate_dynamic_view
-
-
code
-
end
-
-
1
def generate_dynamic_view_content(layout_name, json_data, depth)
-
indent_str = " " * depth
-
-
code = ""
-
code += "#{indent_str} SafeDynamicView(\n"
-
code += "#{indent_str} layoutName = \"#{layout_name}\",\n"
-
code += "#{indent_str} data = data.toMap(viewModel),\n"
-
code += "#{indent_str} fallback = {\n"
-
code += "#{indent_str} // Show error or loading state when dynamic view is not available\n"
-
code += "#{indent_str} Box(\n"
-
code += "#{indent_str} modifier = Modifier.fillMaxSize(),\n"
-
code += "#{indent_str} contentAlignment = Alignment.Center\n"
-
code += "#{indent_str} ) {\n"
-
code += "#{indent_str} Text(\n"
-
code += "#{indent_str} text = \"Dynamic view not available\",\n"
-
code += "#{indent_str} color = Color.Gray\n"
-
code += "#{indent_str} )\n"
-
code += "#{indent_str} }\n"
-
code += "#{indent_str} },\n"
-
code += "#{indent_str} onError = { error ->\n"
-
code += "#{indent_str} // Log error or show error UI\n"
-
code += "#{indent_str} android.util.Log.e(\"DynamicView\", \"Error loading #{layout_name}: \\$error\")\n"
-
code += "#{indent_str} },\n"
-
code += "#{indent_str} onLoading = {\n"
-
code += "#{indent_str} // Show loading indicator\n"
-
code += "#{indent_str} Box(\n"
-
code += "#{indent_str} modifier = Modifier.fillMaxSize(),\n"
-
code += "#{indent_str} contentAlignment = Alignment.Center\n"
-
code += "#{indent_str} ) {\n"
-
code += "#{indent_str} CircularProgressIndicator()\n"
-
code += "#{indent_str} }\n"
-
code += "#{indent_str} }\n"
-
code += "#{indent_str} ) { jsonContent ->\n"
-
code += "#{indent_str} // Parse and render the dynamic JSON content\n"
-
code += "#{indent_str} // This will be handled by the DynamicView implementation\n"
-
code += "#{indent_str} }\n"
-
-
# Add required imports
-
@required_imports.add(:safe_dynamic_view)
-
@required_imports.add(:circular_progress_indicator)
-
@required_imports.add(:box)
-
-
code
-
end
-
-
1
def update_imports(content)
-
1
imports_map = Helpers::ImportManager.get_imports_map(@package_name)
-
-
1
imports_to_add = []
-
1
@required_imports.each do |import_type|
-
1
import_lines = imports_map[import_type]
-
1
if import_lines
-
if import_lines.is_a?(Array)
-
imports_to_add.concat(import_lines)
-
else
-
imports_to_add << import_lines
-
end
-
end
-
end
-
-
# Add imports for included views
-
1
if @included_views && @included_views.any?
-
# Add necessary imports for creating ViewModels
-
imports_to_add << "import android.app.Application" unless imports_to_add.include?("import android.app.Application")
-
imports_to_add << "import androidx.compose.ui.platform.LocalContext" unless imports_to_add.include?("import androidx.compose.ui.platform.LocalContext")
-
-
@included_views.each do |view_name|
-
pascal_name = to_pascal_case(view_name)
-
view_import = "import #{@package_name}.views.#{view_name}.#{pascal_name}View"
-
data_import = "import #{@package_name}.data.#{pascal_name}Data"
-
viewmodel_import = "import #{@package_name}.viewmodels.#{pascal_name}ViewModel"
-
-
imports_to_add << view_import unless imports_to_add.include?(view_import)
-
imports_to_add << data_import unless imports_to_add.include?(data_import)
-
imports_to_add << viewmodel_import unless imports_to_add.include?(viewmodel_import)
-
end
-
end
-
-
# Add imports for custom components
-
1
if @custom_components && @custom_components.any?
-
@custom_components.each do |component_name|
-
component_import = "import #{@package_name}.extensions.#{component_name}"
-
imports_to_add << component_import unless imports_to_add.include?(component_import)
-
end
-
end
-
-
# Add imports for cell views (used in Collection components)
-
1
if @cell_views && @cell_views.any?
-
# Add necessary imports for creating ViewModels in collections
-
imports_to_add << "import androidx.lifecycle.viewmodel.compose.viewModel" unless imports_to_add.include?("import androidx.lifecycle.viewmodel.compose.viewModel")
-
-
# First, remove any old/incorrect cell view imports
-
lines = content.split("\n")
-
@cell_views.each do |cell_class|
-
snake_name = to_snake_case(cell_class)
-
# Remove any existing imports with incorrect capitalization
-
lines.reject! { |line| line.match(/^import #{Regexp.escape(@package_name)}\.views\.#{Regexp.escape(snake_name)}\.\w+View$/) }
-
lines.reject! { |line| line.match(/^import #{Regexp.escape(@package_name)}\.data\.\w+Data$/) && line.downcase.include?(cell_class.downcase) }
-
lines.reject! { |line| line.match(/^import #{Regexp.escape(@package_name)}\.viewmodels\.\w+ViewModel$/) && line.downcase.include?(cell_class.downcase) }
-
end
-
content = lines.join("\n")
-
-
@cell_views.each do |cell_class|
-
# Cell class names are already in PascalCase (e.g., "ProductCell")
-
# Convert to snake_case for folder path
-
snake_name = to_snake_case(cell_class)
-
# Keep the original cell class name for the class itself
-
-
# Add imports for the cell view and data
-
view_import = "import #{@package_name}.views.#{snake_name}.#{cell_class}View"
-
data_import = "import #{@package_name}.data.#{cell_class}Data"
-
viewmodel_import = "import #{@package_name}.viewmodels.#{cell_class}ViewModel"
-
-
imports_to_add << view_import unless imports_to_add.include?(view_import)
-
imports_to_add << data_import unless imports_to_add.include?(data_import)
-
imports_to_add << viewmodel_import unless imports_to_add.include?(viewmodel_import)
-
end
-
end
-
-
1
if imports_to_add.any?
-
lines = content.split("\n")
-
package_index = lines.find_index { |line| line.start_with?("package ") }
-
-
if package_index
-
last_import_index = lines.each_with_index.select { |line, i|
-
i > package_index && line.start_with?("import ")
-
}.map(&:last).max || package_index
-
-
imports_to_add.each do |import|
-
unless lines.any? { |line| line == import }
-
lines.insert(last_import_index + 1, import)
-
last_import_index += 1
-
end
-
end
-
-
content = lines.join("\n")
-
end
-
end
-
-
1
content
-
end
-
-
1
def process_data_binding(text)
-
3
return quote(text) unless text.is_a?(String)
-
-
3
if text.match(/@\{([^}]+)\}/)
-
2
variable = $1
-
2
if variable.include?(' ?? ')
-
1
var_name = variable.split(' ?? ')[0].strip
-
1
"\"\${data.#{var_name}}\""
-
else
-
1
"\"\${data.#{variable}}\""
-
end
-
else
-
1
quote(text)
-
end
-
end
-
-
1
def quote(text)
-
# Escape special characters properly
-
4
escaped = text.gsub('\\', '\\\\\\\\') # Escape backslashes first
-
.gsub('"', '\\"') # Escape quotes
-
.gsub("\n", '\\n') # Escape newlines
-
.gsub("\r", '\\r') # Escape carriage returns
-
.gsub("\t", '\\t') # Escape tabs
-
4
"\"#{escaped}\""
-
end
-
-
1
def indent(text, level)
-
58
return text if level == 0
-
15
spaces = ' ' * level
-
15
text.split("\n").map { |line|
-
15
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
-
1
def to_pascal_case(str)
-
17
str.split(/[_\-]/).map(&:capitalize).join
-
end
-
-
1
def to_camel_case(str)
-
5
pascal = to_pascal_case(str)
-
5
pascal[0].downcase + pascal[1..-1]
-
end
-
-
1
def to_snake_case(str)
-
11
str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
-
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
-
.downcase
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'json'
-
1
require 'fileutils'
-
1
require 'set'
-
1
require_relative '../core/config_manager'
-
1
require_relative '../core/project_finder'
-
1
require_relative '../core/type_converter'
-
1
require_relative 'style_loader'
-
-
1
module KjuiTools
-
1
module Compose
-
1
class DataModelUpdater
-
1
def initialize
-
53
@config = Core::ConfigManager.load_config
-
53
@source_path = Core::ProjectFinder.get_full_source_path || Dir.pwd
-
53
source_directory = @config['source_directory'] || 'src/main'
-
53
@layouts_dir = File.join(@source_path, source_directory, @config['layouts_directory'] || 'assets/Layouts')
-
53
@data_dir = File.join(@source_path, source_directory, @config['data_directory'] || 'kotlin/com/example/kotlinjsonui/sample/data')
-
53
@package_name = @config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.app'
-
53
@mode = @config['mode'] || 'compose'
-
end
-
-
1
def update_data_models(files_to_update = nil)
-
# If specific files provided, only update those
-
11
if files_to_update && !files_to_update.empty?
-
3
puts " Updating data models for #{files_to_update.length} modified files..."
-
3
files_to_update.each do |json_file|
-
3
process_json_file(json_file)
-
end
-
else
-
# Process all JSON files in Layouts directory but exclude Resources and Styles folders
-
8
json_files = Dir.glob(File.join(@layouts_dir, '**/*.json')).reject do |file|
-
# Skip Resources and Styles folders (styles don't need data models)
-
9
file.include?('/Resources/') || file.include?('/Styles/')
-
end
-
-
8
puts " Updating data models for #{json_files.length} files..."
-
8
json_files.each do |json_file|
-
8
process_json_file(json_file)
-
end
-
end
-
end
-
-
1
private
-
-
1
def process_json_file(json_file)
-
11
json_content = File.read(json_file)
-
11
json_data = JSON.parse(json_content)
-
-
# Load and merge styles into the JSON data
-
11
json_data = StyleLoader.load_and_merge(json_data)
-
-
# Extract data properties from JSON
-
11
data_properties = extract_data_properties(json_data)
-
-
# Extract onclick actions from JSON (now includes actions from styles)
-
11
onclick_actions = extract_onclick_actions(json_data)
-
-
# Always create/update data file, even if no properties
-
# Get the view name from file path
-
11
base_name = File.basename(json_file, '.json')
-
-
# Update the Data model file
-
11
update_data_file(base_name, data_properties, onclick_actions)
-
end
-
-
1
def extract_onclick_actions(json_data, actions = Set.new)
-
29
if json_data.is_a?(Hash)
-
# Check for onclick attribute
-
28
if json_data['onclick'] && json_data['onclick'].is_a?(String)
-
8
actions.add(json_data['onclick'])
-
end
-
-
# Process children
-
28
if json_data['child']
-
10
if json_data['child'].is_a?(Array)
-
8
json_data['child'].each do |child|
-
10
extract_onclick_actions(child, actions)
-
end
-
else
-
2
extract_onclick_actions(json_data['child'], actions)
-
end
-
end
-
1
elsif json_data.is_a?(Array)
-
1
json_data.each do |item|
-
2
extract_onclick_actions(item, actions)
-
end
-
end
-
-
29
actions.to_a
-
end
-
-
1
def extract_data_properties(json_data, properties = [], depth = 0)
-
19
if json_data.is_a?(Hash)
-
# Stop if this is an include - includes have their own data models
-
19
return properties if json_data['include']
-
-
# Check for data section at any level, but only process the first one found
-
18
if json_data['data'] && properties.empty?
-
5
if json_data['data'].is_a?(Array)
-
4
json_data['data'].each do |data_item|
-
5
if data_item.is_a?(Hash) && data_item['name']
-
# Check if property already exists (by name) to avoid duplicates
-
6
unless properties.any? { |p| p['name'] == data_item['name'] }
-
# Normalize type using TypeConverter with mode
-
5
normalized = Core::TypeConverter.normalize_data_property(data_item, @mode)
-
5
properties << normalized
-
end
-
end
-
end
-
1
elsif json_data['data'].is_a?(Hash)
-
# Handle simple data object format from styles
-
1
json_data['data'].each do |name, value|
-
10
unless properties.any? { |p| p['name'] == name }
-
# Infer type from value
-
4
class_type = if value.is_a?(Integer)
-
1
'Int'
-
3
elsif value.is_a?(Float)
-
1
'Float'
-
2
elsif value.is_a?(TrueClass) || value.is_a?(FalseClass)
-
1
'Boolean'
-
else
-
1
'String'
-
end
-
-
4
properties << {
-
'name' => name,
-
'class' => class_type,
-
'defaultValue' => value
-
}
-
end
-
end
-
end
-
end
-
-
# If we haven't found data yet, continue searching in children
-
18
if properties.empty? && json_data['child']
-
7
if json_data['child'].is_a?(Array)
-
6
json_data['child'].each do |child|
-
7
extract_data_properties(child, properties, depth + 1)
-
# Stop after finding the first data section
-
7
break unless properties.empty?
-
end
-
else
-
1
extract_data_properties(json_data['child'], properties, depth + 1)
-
end
-
end
-
elsif json_data.is_a?(Array)
-
json_data.each do |item|
-
extract_data_properties(item, properties, depth)
-
# Stop after finding the first data section
-
break unless properties.empty?
-
end
-
end
-
-
18
properties
-
end
-
-
1
def update_data_file(base_name, data_properties, onclick_actions = [])
-
# Convert base_name to PascalCase for searching
-
11
pascal_view_name = to_pascal_case(base_name)
-
-
# Check for existing file with different casing
-
11
existing_file = find_existing_data_file(pascal_view_name)
-
-
11
if existing_file
-
# Extract the actual data class name from the existing file
-
existing_class_name = extract_class_name(existing_file)
-
if existing_class_name
-
# Use the exact class name from the existing file
-
view_name = existing_class_name.sub(/Data$/, '')
-
else
-
# Fallback to pascal case if we can't extract the name
-
view_name = pascal_view_name
-
end
-
data_file_path = existing_file
-
else
-
# For new files, use pascal case
-
11
view_name = pascal_view_name
-
11
data_file_path = File.join(@data_dir, "#{view_name}Data.kt")
-
# If file doesn't exist, create it with empty data structure
-
11
unless File.exist?(data_file_path)
-
# Create directory if needed
-
11
FileUtils.mkdir_p(@data_dir)
-
end
-
end
-
-
# Generate new content
-
11
content = generate_data_content(view_name, data_properties, onclick_actions)
-
-
# Write the updated content
-
11
File.write(data_file_path, content)
-
11
puts " Updated Data model: #{data_file_path}"
-
end
-
-
1
def find_existing_data_file(view_name)
-
# Try exact match first
-
13
exact_path = File.join(@data_dir, "#{view_name}Data.kt")
-
13
return exact_path if File.exist?(exact_path)
-
-
# Try case-insensitive search
-
12
Dir.glob(File.join(@data_dir, '*Data.kt')).find do |file|
-
File.basename(file, '.kt').downcase == "#{view_name}Data".downcase
-
end
-
end
-
-
1
def extract_class_name(file_path)
-
2
content = File.read(file_path)
-
2
if match = content.match(/data\s+class\s+(\w+Data)\s*\(/)
-
1
match[1]
-
else
-
nil
-
end
-
end
-
-
1
def generate_data_content(view_name, data_properties, onclick_actions = [])
-
16
content = <<~KOTLIN
-
package #{@package_name}.data
-
-
import androidx.compose.runtime.MutableState
-
import androidx.compose.runtime.mutableStateOf
-
import #{@package_name}.viewmodels.#{view_name}ViewModel
-
KOTLIN
-
-
# Add Color import if any property uses Color type
-
28
if data_properties.any? { |prop| prop['class'] == 'Color' }
-
1
content += "import androidx.compose.ui.graphics.Color\n"
-
end
-
-
16
content += "\ndata class #{view_name}Data(\n"
-
-
16
if data_properties.empty?
-
8
content += " // No data properties defined in JSON\n"
-
8
content += " val placeholder: String = \"placeholder\"\n"
-
else
-
# Add each property with correct type and default value
-
8
data_properties.each_with_index do |prop, index|
-
12
name = prop['name']
-
12
class_type = map_to_kotlin_type(prop['class'])
-
12
default_value = prop['defaultValue']
-
-
# If no default value or nil, make it nullable
-
12
if default_value.nil? || default_value == 'nil'
-
# Don't add ? if type already ends with ? (already nullable)
-
1
if class_type.end_with?('?')
-
content += " var #{name}: #{class_type} = null"
-
else
-
1
content += " var #{name}: #{class_type}? = null"
-
end
-
else
-
11
formatted_value = format_default_value(default_value, prop['class'])
-
11
content += " var #{name}: #{class_type} = #{formatted_value}"
-
end
-
-
# Add comma if not last property
-
12
content += "," if index < data_properties.length - 1
-
12
content += "\n"
-
end
-
end
-
-
16
content += ") {\n"
-
-
# Add companion object with update function
-
16
content += " companion object {\n"
-
16
content += " // Update properties from map\n"
-
16
content += " fun fromMap(map: Map<String, Any>): #{view_name}Data {\n"
-
16
content += " return #{view_name}Data(\n"
-
-
16
if !data_properties.empty?
-
8
data_properties.each_with_index do |prop, index|
-
12
name = prop['name']
-
12
class_type = prop['class']
-
12
kotlin_type = map_to_kotlin_type(class_type)
-
-
# Generate conversion code based on type
-
12
content += " #{name} = "
-
-
12
case class_type
-
when 'String'
-
8
content += "map[\"#{name}\"] as? String ?: \"\""
-
when 'Int'
-
1
content += "(map[\"#{name}\"] as? Number)?.toInt() ?: 0"
-
when 'Double'
-
content += "(map[\"#{name}\"] as? Number)?.toDouble() ?: 0.0"
-
when 'Float'
-
1
content += "(map[\"#{name}\"] as? Number)?.toFloat() ?: 0f"
-
when 'Bool', 'Boolean'
-
1
content += "map[\"#{name}\"] as? Boolean ?: false"
-
when 'Color'
-
1
content += "map[\"#{name}\"] as? Color ?: Color.Unspecified"
-
when 'CollectionDataSource'
-
content += "com.kotlinjsonui.data.CollectionDataSource()"
-
when /^List<.*>$/
-
content += "map[\"#{name}\"] as? #{kotlin_type} ?: emptyList()"
-
when /^Map<.*>$/
-
content += "map[\"#{name}\"] as? #{kotlin_type} ?: emptyMap()"
-
else
-
# For custom types, try to cast directly
-
content += "map[\"#{name}\"] as? #{kotlin_type}"
-
end
-
-
12
content += "," if index < data_properties.length - 1
-
12
content += "\n"
-
end
-
else
-
8
content += " placeholder = \"placeholder\"\n"
-
end
-
-
16
content += " )\n"
-
16
content += " }\n"
-
16
content += " }\n"
-
-
# Add toMap function with viewModel parameter
-
16
content += "\n"
-
16
content += " // Convert properties to map for runtime use\n"
-
16
content += " fun toMap(viewModel: #{view_name}ViewModel? = null): MutableMap<String, Any> {\n"
-
16
content += " val map = mutableMapOf<String, Any>()\n"
-
-
# Add data properties
-
16
if !data_properties.empty?
-
8
content += " \n"
-
8
content += " // Data properties\n"
-
8
data_properties.each do |prop|
-
12
name = prop['name']
-
12
default_value = prop['defaultValue']
-
-
# If it's nullable, check for null
-
12
if default_value.nil? || default_value == 'nil'
-
1
content += " #{name}?.let { map[\"#{name}\"] = it }\n"
-
else
-
11
content += " map[\"#{name}\"] = #{name}\n"
-
end
-
end
-
end
-
-
# Add onclick actions if viewModel is provided
-
16
if !onclick_actions.empty?
-
2
content += " \n"
-
2
content += " // Add onclick action lambdas if viewModel is provided\n"
-
2
content += " viewModel?.let { vm ->\n"
-
2
onclick_actions.each do |action|
-
4
content += " map[\"#{action}\"] = { vm.#{action}() }\n"
-
end
-
2
content += " }\n"
-
end
-
-
16
if data_properties.empty? && onclick_actions.empty?
-
6
content += " // No properties to add\n"
-
end
-
-
16
content += " \n"
-
16
content += " return map\n"
-
16
content += " }\n"
-
16
content += "}\n"
-
16
content
-
end
-
-
1
def map_to_kotlin_type(json_class)
-
38
case json_class
-
when 'String'
-
17
'String'
-
when 'Int'
-
3
'Int'
-
when 'Double'
-
1
'Double'
-
when 'Float'
-
3
'Float'
-
when 'Bool', 'Boolean'
-
4
'Boolean'
-
when 'CGFloat'
-
1
'Float'
-
when 'Color'
-
3
'Color'
-
when 'CollectionDataSource'
-
# Use the actual CollectionDataSource type
-
1
'com.kotlinjsonui.data.CollectionDataSource'
-
when /^\(\) -> Unit$/
-
# Non-optional callback becomes optional in data class
-
1
'(() -> Unit)?'
-
when /^\((.+)\) -> Unit$/
-
# Callback with parameters becomes optional
-
1
"((#{$1}) -> Unit)?"
-
when /^\(\(\) -> Unit\)\?$/
-
# Already optional, keep as is
-
1
'(() -> Unit)?'
-
when /^\(\((.+)\) -> Unit\)\?$/
-
# Already optional with params, keep as is
-
1
"((#{$1}) -> Unit)?"
-
else
-
# Return as-is for custom types
-
1
json_class
-
end
-
end
-
-
1
def format_default_value(value, json_class)
-
29
case json_class
-
when 'String'
-
# Handle '' as empty string (common shorthand)
-
8
if value == "''"
-
'""'
-
else
-
# For String class, add quotes
-
8
"\"#{value}\""
-
end
-
when 'Bool', 'Boolean'
-
# Convert to boolean
-
4
if value.is_a?(TrueClass) || value.is_a?(FalseClass)
-
3
value.to_s
-
else
-
1
value.to_s.downcase == 'true' ? 'true' : 'false'
-
end
-
when 'Int'
-
# Ensure it's an integer
-
3
value.to_i.to_s
-
when 'Double'
-
# Ensure it's a double
-
1
"#{value.to_f}"
-
when 'Float', 'CGFloat'
-
# Ensure it's a float with f suffix
-
3
"#{value.to_f}f"
-
when 'Color'
-
# Handle color values
-
5
if value.is_a?(String) && value.start_with?('#')
-
2
"Color(android.graphics.Color.parseColor(\"#{value}\"))"
-
3
elsif value.is_a?(String) && value.start_with?('Color.')
-
1
value # Direct Color reference like Color.Red
-
2
elsif value.is_a?(String) && value.downcase.include?('color')
-
# Map common color names
-
case value.downcase
-
when 'red' then 'Color.Red'
-
when 'green' then 'Color.Green'
-
when 'blue' then 'Color.Blue'
-
when 'black' then 'Color.Black'
-
when 'white' then 'Color.White'
-
when 'gray', 'grey' then 'Color.Gray'
-
when 'yellow' then 'Color.Yellow'
-
when 'cyan' then 'Color.Cyan'
-
when 'magenta' then 'Color.Magenta'
-
else 'Color.Unspecified'
-
end
-
else
-
2
'Color.Unspecified'
-
end
-
when 'CollectionDataSource'
-
# Return the actual default value string or create new instance
-
1
if value.is_a?(String) && value == 'CollectionDataSource()'
-
1
'com.kotlinjsonui.data.CollectionDataSource()'
-
else
-
'com.kotlinjsonui.data.CollectionDataSource()'
-
end
-
when /^List<.*>$/
-
# Handle generic List types
-
2
if value.is_a?(Array) && value.empty?
-
1
'emptyList()'
-
1
elsif value == '[]' || value == []
-
1
'emptyList()'
-
else
-
'emptyList()'
-
end
-
when /^Map<.*>$/
-
# Handle generic Map types
-
2
if value.is_a?(Hash) && value.empty?
-
1
'emptyMap()'
-
1
elsif value == '{}' || value == {} || value == '{}'
-
1
'emptyMap()'
-
else
-
'emptyMap()'
-
end
-
else
-
# For all other cases, use value as-is
-
value
-
end
-
end
-
-
1
def to_pascal_case(str)
-
# Handle various naming patterns
-
14
snake = str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
-
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
-
.downcase
-
14
snake.split(/[_\-]/).map(&:capitalize).join
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'json'
-
1
require 'fileutils'
-
1
require_relative '../../core/config_manager'
-
1
require_relative '../../core/project_finder'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Generators
-
1
class CellGenerator
-
1
def initialize(name, options = {})
-
16
@name = name
-
16
@options = options
-
16
@config = Core::ConfigManager.load_config
-
end
-
-
1
def generate
-
# Parse name for subdirectories
-
14
parts = @name.split('/')
-
14
cell_name = parts.last
-
14
subdirectory = parts[0...-1].join('/') if parts.length > 1
-
# Convert subdirectory to snake_case for JSON layouts
-
18
snake_subdirectory = parts[0...-1].map { |p| to_snake_case(p) }.join('/') if parts.length > 1
-
-
# Keep original PascalCase if provided, otherwise convert
-
# If the name is already in PascalCase (e.g., ProductCell), keep it
-
14
cell_class_name = cell_name
-
14
json_file_name = to_snake_case(cell_name)
-
-
# Get directories from config
-
14
source_dir = @config['source_directory'] || 'src/main'
-
14
layouts_dir = @config['layouts_directory'] || 'assets/Layouts'
-
14
view_dir = @config['view_directory'] || 'kotlin/com/example/kotlinjsonui/sample/views'
-
14
viewmodel_dir = @config['viewmodel_directory'] || 'kotlin/com/example/kotlinjsonui/sample/viewmodels'
-
14
data_dir = @config['data_directory'] || 'kotlin/com/example/kotlinjsonui/sample/data'
-
14
package_name = @config['package_name'] || 'com.example.kotlinjsonui.sample'
-
-
# Create full paths with subdirectory support
-
# Each cell gets its own directory (using snake_case for Android)
-
14
cell_folder_name = to_snake_case(cell_name)
-
-
14
if subdirectory
-
# JSON uses snake_case subdirectory, view/viewmodel/data use original casing
-
3
json_path = File.join(source_dir, layouts_dir, snake_subdirectory)
-
3
swift_path = File.join(source_dir, view_dir, subdirectory, cell_folder_name)
-
3
viewmodel_path = File.join(source_dir, viewmodel_dir, subdirectory)
-
3
data_path = File.join(source_dir, data_dir, subdirectory)
-
else
-
11
json_path = File.join(source_dir, layouts_dir)
-
11
swift_path = File.join(source_dir, view_dir, cell_folder_name)
-
11
viewmodel_path = File.join(source_dir, viewmodel_dir)
-
11
data_path = File.join(source_dir, data_dir)
-
end
-
-
# Create directories if they don't exist
-
14
FileUtils.mkdir_p(json_path)
-
14
FileUtils.mkdir_p(swift_path)
-
14
FileUtils.mkdir_p(viewmodel_path)
-
14
FileUtils.mkdir_p(data_path)
-
-
# Create JSON file
-
14
json_file = File.join(json_path, "#{json_file_name}.json")
-
14
create_json_template(json_file, cell_class_name)
-
-
# Create Main Cell View file (add View suffix to class name)
-
14
main_kotlin_file = File.join(swift_path, "#{cell_class_name}View.kt")
-
14
create_main_cell_template(main_kotlin_file, cell_class_name, json_file_name, subdirectory, package_name)
-
-
# Create Generated View file
-
14
generated_kotlin_file = File.join(swift_path, "#{cell_class_name}GeneratedView.kt")
-
14
create_generated_cell_template(generated_kotlin_file, cell_class_name, json_file_name, subdirectory, package_name)
-
-
# Create Data file with item property
-
14
data_file = File.join(data_path, "#{cell_class_name}Data.kt")
-
14
create_cell_data_template(data_file, cell_class_name, package_name)
-
-
# Create ViewModel file
-
14
viewmodel_file = File.join(viewmodel_path, "#{cell_class_name}ViewModel.kt")
-
14
create_cell_viewmodel_template(viewmodel_file, cell_class_name, json_file_name, subdirectory, package_name)
-
-
14
puts "Generated Collection Cell view:"
-
14
puts " JSON: #{json_file}"
-
14
puts " Main View: #{main_kotlin_file}"
-
14
puts " Generated View: #{generated_kotlin_file}"
-
14
puts " Data: #{data_file}"
-
14
puts " ViewModel: #{viewmodel_file}"
-
14
puts ""
-
14
puts "Next steps:"
-
14
puts " 1. Edit the JSON layout in #{json_file}"
-
14
puts " 2. Run 'kjui build' to generate the Compose code"
-
14
puts " 3. Use this cell in Collection components with cellClasses: [\"#{cell_class_name}\"]"
-
end
-
-
1
private
-
-
1
def create_json_template(file_path, class_name)
-
14
return if File.exist?(file_path)
-
-
json_content = {
-
13
"type" => "View",
-
"orientation" => "horizontal",
-
"padding" => 12,
-
"background" => "#F9F9F9",
-
"cornerRadius" => 6,
-
"child" => [
-
{
-
"type" => "Text",
-
"text" => "@{item.title}",
-
"fontSize" => 14,
-
"weight" => 1
-
},
-
{
-
"type" => "Text",
-
"text" => "@{item.value}",
-
"fontSize" => 14,
-
"fontWeight" => "bold"
-
}
-
]
-
}
-
-
13
File.write(file_path, JSON.pretty_generate(json_content))
-
end
-
-
1
def create_main_cell_template(file_path, class_name, json_name, subdirectory, package_name)
-
14
return if File.exist?(file_path)
-
-
# Calculate relative package path
-
14
view_package = if subdirectory
-
3
"#{package_name}.views.#{subdirectory.gsub('/', '.')}.#{to_snake_case(class_name)}"
-
else
-
11
"#{package_name}.views.#{to_snake_case(class_name)}"
-
end
-
-
14
content = <<~KOTLIN
-
package #{view_package}
-
-
import androidx.compose.runtime.Composable
-
import androidx.compose.ui.Modifier
-
import #{package_name}.data.#{class_name}Data
-
-
@Composable
-
fun #{class_name}View(
-
data: #{class_name}Data,
-
modifier: Modifier = Modifier
-
) {
-
// This is a cell view for use in Collection components
-
// The data parameter contains an 'item' property with the cell's data
-
-
#{class_name}GeneratedView(
-
data = data,
-
modifier = modifier
-
)
-
}
-
KOTLIN
-
-
14
File.write(file_path, content)
-
end
-
-
1
def create_generated_cell_template(file_path, class_name, json_name, subdirectory, package_name)
-
14
return if File.exist?(file_path)
-
-
# Calculate relative package path
-
14
view_package = if subdirectory
-
3
"#{package_name}.views.#{subdirectory.gsub('/', '.')}.#{to_snake_case(class_name)}"
-
else
-
11
"#{package_name}.views.#{to_snake_case(class_name)}"
-
end
-
-
14
content = <<~KOTLIN
-
package #{view_package}
-
-
import androidx.compose.foundation.background
-
import androidx.compose.foundation.layout.*
-
import androidx.compose.material3.*
-
import androidx.compose.runtime.Composable
-
import androidx.compose.ui.Alignment
-
import androidx.compose.ui.Modifier
-
import androidx.compose.ui.graphics.Color
-
import androidx.compose.ui.text.font.FontWeight
-
import androidx.compose.ui.text.style.TextAlign
-
import androidx.compose.ui.unit.dp
-
import androidx.compose.ui.unit.sp
-
import #{package_name}.data.#{class_name}Data
-
import androidx.compose.material3.CircularProgressIndicator
-
import androidx.compose.foundation.layout.Box
-
import com.kotlinjsonui.core.DynamicModeManager
-
import com.kotlinjsonui.core.SafeDynamicView
-
-
@Composable
-
fun #{class_name}GeneratedView(
-
data: #{class_name}Data,
-
modifier: Modifier = Modifier
-
) {
-
// Generated Compose code from #{json_name}.json
-
// This will be updated when you run 'kjui build'
-
// >>> GENERATED_CODE_START
-
// Check if Dynamic Mode is active
-
if (DynamicModeManager.isActive()) {
-
// Dynamic Mode - use SafeDynamicView for real-time updates
-
SafeDynamicView(
-
layoutName = "#{json_name}",
-
data = data.toMap(),
-
modifier = modifier,
-
fallback = {
-
// Show error or loading state when dynamic view is not available
-
Box(
-
modifier = Modifier.fillMaxSize(),
-
contentAlignment = Alignment.Center
-
) {
-
Text(
-
text = "Dynamic view not available",
-
color = Color.Gray
-
)
-
}
-
},
-
onError = { error ->
-
// Log error or show error UI
-
android.util.Log.e("DynamicView", "Error loading #{json_name}: \\$error")
-
},
-
onLoading = {
-
// Show loading indicator
-
Box(
-
modifier = Modifier.fillMaxSize(),
-
contentAlignment = Alignment.Center
-
) {
-
CircularProgressIndicator()
-
}
-
}
-
) { jsonContent ->
-
// Parse and render the dynamic JSON content
-
// This will be handled by the DynamicView implementation
-
}
-
} else {
-
// Static Mode - use generated code
-
// TODO: Generated content will appear here when you run 'kjui build'
-
Box(
-
modifier = modifier
-
.fillMaxWidth()
-
.padding(16.dp)
-
) {
-
Text("Cell content will be generated from #{json_name}.json")
-
}
-
}
-
// >>> GENERATED_CODE_END
-
}
-
KOTLIN
-
-
14
File.write(file_path, content)
-
end
-
-
1
def create_cell_data_template(file_path, class_name, package_name)
-
14
return if File.exist?(file_path)
-
-
14
content = <<~KOTLIN
-
package #{package_name}.data
-
-
data class #{class_name}Data(
-
var item: Map<String, Any> = emptyMap()
-
) {
-
companion object {
-
// Update properties from map
-
fun fromMap(map: Map<String, Any>): #{class_name}Data {
-
return #{class_name}Data(
-
item = map["item"] as? Map<String, Any> ?: emptyMap()
-
)
-
}
-
}
-
-
// Convert properties to map for runtime use
-
fun toMap(): MutableMap<String, Any> {
-
val map = mutableMapOf<String, Any>()
-
-
// Data properties
-
map["item"] = item
-
-
return map
-
}
-
}
-
KOTLIN
-
-
14
File.write(file_path, content)
-
end
-
-
1
def create_cell_viewmodel_template(file_path, class_name, json_name, subdirectory, package_name)
-
14
return if File.exist?(file_path)
-
-
14
content = <<~KOTLIN
-
package #{package_name}.viewmodels
-
-
import android.app.Application
-
import androidx.lifecycle.AndroidViewModel
-
import androidx.lifecycle.viewModelScope
-
import androidx.compose.runtime.mutableStateOf
-
import androidx.compose.runtime.getValue
-
import androidx.compose.runtime.setValue
-
import kotlinx.coroutines.launch
-
import #{package_name}.data.#{class_name}Data
-
-
class #{class_name}ViewModel(application: Application) : AndroidViewModel(application) {
-
// Cell data - managed by parent Collection
-
var data by mutableStateOf(#{class_name}Data())
-
private set
-
-
// This is a cell view model
-
// Data is typically provided by the parent Collection component
-
-
fun updateData(newData: #{class_name}Data) {
-
data = newData
-
}
-
-
fun updateItem(item: Map<String, Any>) {
-
data = data.copy(item = item)
-
}
-
}
-
KOTLIN
-
-
14
File.write(file_path, content)
-
end
-
-
1
def to_pascal_case(str)
-
str.split(/[_\-]/).map(&:capitalize).join
-
end
-
-
1
def to_snake_case(str)
-
60
str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
-
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
-
.downcase
-
end
-
-
1
def to_camel_case(str)
-
pascal = to_pascal_case(str)
-
pascal[0].downcase + pascal[1..-1]
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'fileutils'
-
1
require 'json'
-
1
require_relative '../../core/logger'
-
1
require_relative 'kotlin_component_generator'
-
1
require_relative 'dynamic_component_generator'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Generators
-
1
class ConverterGenerator
-
1
def initialize(name, options = {})
-
32
@name = name
-
# Keep original PascalCase name for component
-
32
@component_pascal_case = name # e.g., MyTestCard
-
32
@component_snake_case = to_snake_case(name) # e.g., my_test_card
-
32
@class_name = name + "Component" # e.g., MyTestCardComponent
-
32
@options = options
-
32
@logger = Core::Logger
-
end
-
-
1
def generate
-
5
@logger.info "Generating custom converter: #{@class_name}"
-
-
# Create converter file for static generation
-
5
create_converter_file
-
-
# Update component mappings file
-
5
update_mappings_file
-
-
# Create Kotlin component file using separate generator
-
5
kotlin_generator = KotlinComponentGenerator.new(@name, @options)
-
5
kotlin_generator.generate
-
-
# Generate dynamic component file
-
5
dynamic_generator = DynamicComponentGenerator.new(@name, @options)
-
5
dynamic_generator.generate
-
-
# Create or update DynamicComponentInitializer files
-
5
create_dynamic_initializers
-
-
# Generate attribute definition file
-
5
generate_attribute_definition_file
-
-
5
@logger.success "Successfully generated converter: #{@class_name}"
-
5
@logger.info "Converter file created at: kjui_tools/lib/compose/components/extensions/#{@component_snake_case}_component.rb"
-
5
@logger.info "Mappings file updated with '#{@component_pascal_case}' => '#{@class_name}'"
-
end
-
-
1
private
-
-
1
def create_converter_file
-
# Get the path relative to this generator file
-
5
generator_dir = File.dirname(__FILE__)
-
# Go up to lib/compose/components/extensions
-
5
extensions_dir = File.join(generator_dir, '..', 'components', 'extensions')
-
5
extensions_dir = File.expand_path(extensions_dir)
-
5
FileUtils.mkdir_p(extensions_dir)
-
-
5
file_path = File.join(extensions_dir, "#{@component_snake_case}_component.rb")
-
-
5
if File.exist?(file_path)
-
@logger.warn "Converter file already exists: #{file_path}"
-
print "Overwrite? (y/n): "
-
response = gets.chomp.downcase
-
return unless response == 'y'
-
end
-
-
5
File.write(file_path, converter_template)
-
5
@logger.info "Created converter file: #{file_path}"
-
end
-
-
1
def update_mappings_file
-
# Get the path relative to this generator file
-
5
generator_dir = File.dirname(__FILE__)
-
5
mappings_file = File.join(generator_dir, '..', 'components', 'extensions', 'component_mappings.rb')
-
5
mappings_file = File.expand_path(mappings_file)
-
-
# Create new mappings file if it doesn't exist
-
5
if !File.exist?(mappings_file)
-
5
create_initial_mappings_file
-
5
return
-
end
-
-
# Read existing mappings
-
content = File.read(mappings_file)
-
-
# Check if mapping already exists
-
if content.include?("'#{@component_pascal_case}' =>")
-
@logger.warn "Mapping for '#{@component_pascal_case}' already exists in component_mappings.rb"
-
return
-
end
-
-
# Add require statement if not present
-
require_line = "require_relative '#{@component_snake_case}_component'"
-
unless content.include?(require_line)
-
# Add require after other requires or at the beginning of the module
-
if content =~ /^require_relative/
-
# Add after the last require
-
content.sub!(/^((?:require_relative.*\n)+)/) do
-
"#{$1}#{require_line}\n"
-
end
-
else
-
# Add before the module declaration
-
content.sub!(/^(# Auto-generated.*\n)\n/) do
-
"#{$1}\n#{require_line}\n\n"
-
end
-
end
-
end
-
-
# Add new mapping
-
new_mapping = " '#{@component_pascal_case}' => #{@class_name},"
-
-
# Insert the new mapping before the closing brace of COMPONENT_MAPPINGS
-
content.sub!(/(COMPONENT_MAPPINGS = \{.*?)(,?)(\s*)( \}\.freeze)/m) do
-
existing_mappings = $1
-
last_comma = $2
-
whitespace = $3
-
closing = $4
-
-
# If there are existing mappings, add the new one with proper formatting
-
if existing_mappings =~ /=>/
-
# Ensure the last existing mapping has a comma, then add the new mapping
-
"#{existing_mappings},\n#{new_mapping}\n#{closing}"
-
else
-
# First mapping
-
"#{existing_mappings}\n#{new_mapping}\n#{closing}"
-
end
-
end
-
-
File.write(mappings_file, content)
-
@logger.info "Updated component_mappings.rb with new mapping"
-
end
-
-
1
def create_initial_mappings_file
-
# Get the path relative to this generator file
-
6
generator_dir = File.dirname(__FILE__)
-
6
extensions_dir = File.join(generator_dir, '..', 'components', 'extensions')
-
6
extensions_dir = File.expand_path(extensions_dir)
-
6
FileUtils.mkdir_p(extensions_dir)
-
-
6
mappings_file = File.join(extensions_dir, 'component_mappings.rb')
-
-
6
content = <<~RUBY
-
# frozen_string_literal: true
-
-
# This file maps custom component types to their converter classes
-
# Auto-generated by kjui g converter command
-
-
require_relative '#{@component_snake_case}_component'
-
-
module KjuiTools
-
module Compose
-
module Components
-
module Extensions
-
COMPONENT_MAPPINGS = {
-
'#{@component_pascal_case}' => #{@class_name},
-
}.freeze
-
end
-
end
-
end
-
end
-
RUBY
-
-
6
File.write(mappings_file, content)
-
6
@logger.info "Created component_mappings.rb with initial mapping"
-
end
-
-
1
def converter_template
-
9
<<~RUBY
-
# frozen_string_literal: true
-
-
require_relative '../../helpers/modifier_builder'
-
-
module KjuiTools
-
module Compose
-
module Components
-
module Extensions
-
class #{@class_name}
-
def self.generate(json_data, depth, required_imports = nil, parent_type = nil)
-
required_imports&.add(:box)
-
-
# Check if this is a container component
-
children = json_data['children'] || json_data['child']
-
is_container = children && children.is_a?(Array) && !children.empty?
-
-
# Collect parameters
-
params = []
-
-
# Helper method to format values
-
format_value = lambda do |value, type|
-
case type.downcase
-
when 'string', 'text'
-
# Use ResourceResolver to process strings (checks for resources)
-
Helpers::ResourceResolver.process_text(value, required_imports)
-
when 'int', 'integer', 'float', 'double', 'bool', 'boolean'
-
value.to_s
-
when 'color'
-
# Use ResourceResolver to process colors
-
Helpers::ResourceResolver.process_color(value, required_imports)
-
else
-
value.to_s
-
end
-
end
-
#{generate_parameter_collection}
-
-
# Build modifiers
-
modifiers = []
-
modifiers.concat(Helpers::ModifierBuilder.build_size(json_data))
-
modifiers.concat(Helpers::ModifierBuilder.build_padding(json_data))
-
modifiers.concat(Helpers::ModifierBuilder.build_margins(json_data))
-
modifiers.concat(Helpers::ModifierBuilder.build_background(json_data, required_imports))
-
-
if is_container
-
# Container component with children
-
code = indent("#{@component_pascal_case}(", depth)
-
-
if !params.empty?
-
params.each_with_index do |param, index|
-
separator = index == params.length - 1 ? '' : ','
-
code += "\\n" + indent("\#{param}\#{separator}", depth + 1)
-
end
-
end
-
-
if !modifiers.empty?
-
modifier_str = Helpers::ModifierBuilder.format(modifiers, depth)
-
code += (params.empty? ? modifier_str : "," + modifier_str)
-
end
-
-
code += "\\n" + indent(") {", depth)
-
-
# Process children - return with metadata for ComposeBuilder to handle
-
return {
-
code: code,
-
children: children,
-
closing: "\\n" + indent("}", depth)
-
}
-
else
-
# Non-container component
-
if params.empty? && modifiers.empty?
-
code = indent("#{@component_pascal_case}()", depth)
-
else
-
code = indent("#{@component_pascal_case}(", depth)
-
-
if !params.empty?
-
params.each_with_index do |param, index|
-
separator = index == params.length - 1 ? '' : ','
-
code += "\\n" + indent("\#{param}\#{separator}", depth + 1)
-
end
-
end
-
-
if !modifiers.empty?
-
modifier_str = Helpers::ModifierBuilder.format(modifiers, depth)
-
code += (params.empty? ? modifier_str : "," + modifier_str)
-
end
-
-
code += "\\n" + indent(")", depth)
-
end
-
end
-
-
code
-
end
-
-
private
-
-
def self.indent(text, level)
-
return text if level == 0
-
spaces = ' ' * level
-
text.split("\\n").map { |line|
-
line.empty? ? line : spaces + line
-
}.join("\\n")
-
end
-
end
-
end
-
end
-
end
-
end
-
RUBY
-
end
-
-
1
def generate_parameter_collection
-
13
return "" if !@options[:attributes] || @options[:attributes].empty?
-
-
7
lines = []
-
7
@options[:attributes].each do |key, type|
-
# Check if this is a binding property (starts with @)
-
14
is_binding = key.start_with?('@')
-
14
actual_key = is_binding ? key[1..-1] : key
-
-
14
lines << " if json_data['#{actual_key}']"
-
14
lines << " value = json_data['#{actual_key}']"
-
14
lines << " if value.is_a?(String) && value.match?(/@\\{([^}]+)\\}/)"
-
14
lines << " # Handle binding"
-
14
lines << " prop_name = value[2..-2]"
-
14
lines << " params << \"#{actual_key} = data.\#{prop_name}\""
-
14
lines << " else"
-
14
lines << " # Handle static value"
-
14
lines << " formatted_value = format_value.call(value, '#{type}')"
-
14
lines << " params << \"#{actual_key} = \#{formatted_value}\" if formatted_value"
-
14
lines << " end"
-
14
lines << " end"
-
end
-
7
lines.join("\n")
-
end
-
-
1
def to_snake_case(str)
-
36
str.gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
-
.gsub(/([a-z\d])([A-Z])/,'\1_\2')
-
.downcase
-
end
-
-
1
def create_dynamic_initializers
-
5
config = Core::ConfigManager.load_config
-
5
base_path = config['_config_dir'] || Dir.pwd
-
5
source_directory = config['source_directory'] || 'src/main'
-
5
package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
-
-
# Create debug version
-
5
debug_dir = File.join(
-
base_path,
-
source_directory.gsub('main', 'debug'),
-
'kotlin',
-
package_name.gsub('.', '/')
-
)
-
5
FileUtils.mkdir_p(debug_dir)
-
-
5
debug_file = File.join(debug_dir, 'DynamicComponentInitializer.kt')
-
-
# Only create if it doesn't exist yet
-
5
if !File.exist?(debug_file)
-
5
File.write(debug_file, generate_debug_initializer_content(package_name))
-
5
@logger.info "Created DynamicComponentInitializer (debug)"
-
end
-
-
# Create release version
-
5
release_dir = File.join(
-
base_path,
-
source_directory.gsub('main', 'release'),
-
'kotlin',
-
package_name.gsub('.', '/')
-
)
-
5
FileUtils.mkdir_p(release_dir)
-
-
5
release_file = File.join(release_dir, 'DynamicComponentInitializer.kt')
-
-
# Only create if it doesn't exist yet
-
5
if !File.exist?(release_file)
-
5
File.write(release_file, generate_release_initializer_content(package_name))
-
5
@logger.info "Created DynamicComponentInitializer (release)"
-
end
-
end
-
-
1
def generate_debug_initializer_content(package_name)
-
6
<<~KOTLIN
-
package #{package_name}
-
-
import androidx.compose.runtime.Composable
-
import com.google.gson.JsonObject
-
import com.kotlinjsonui.core.Configuration
-
import #{package_name}.dynamic.DynamicComponentRegistry
-
-
/**
-
* Debug-only initializer for custom components in dynamic mode
-
* Auto-generated by kjui converter generator
-
*/
-
object DynamicComponentInitializer {
-
-
/**
-
* Register custom component handler for dynamic mode
-
* This is only available in debug builds where DynamicComponentRegistry exists
-
*/
-
fun initialize() {
-
Configuration.customComponentHandler = { type, json, data ->
-
DynamicComponentRegistry.createCustomComponent(type, json, data)
-
}
-
}
-
}
-
KOTLIN
-
end
-
-
1
def generate_release_initializer_content(package_name)
-
6
<<~KOTLIN
-
package #{package_name}
-
-
/**
-
* Release version of DynamicComponentInitializer (no-op)
-
* Auto-generated by kjui converter generator
-
*/
-
object DynamicComponentInitializer {
-
-
/**
-
* No-op in release builds
-
*/
-
fun initialize() {
-
// Dynamic component registry is not available in release builds
-
}
-
}
-
KOTLIN
-
end
-
-
# Generate attribute definition file for validation
-
1
def generate_attribute_definition_file
-
# Skip if no attributes defined
-
5
return if !@options[:attributes] || @options[:attributes].empty?
-
-
# Get the path relative to this generator file
-
4
generator_dir = File.dirname(__FILE__)
-
4
definitions_dir = File.join(generator_dir, '..', 'components', 'extensions', 'attribute_definitions')
-
4
definitions_dir = File.expand_path(definitions_dir)
-
4
FileUtils.mkdir_p(definitions_dir)
-
-
4
file_path = File.join(definitions_dir, "#{@component_pascal_case}.json")
-
-
# Build attribute definitions
-
4
attribute_defs = {}
-
4
@options[:attributes].each do |key, type|
-
# Remove @ prefix if present (for binding properties)
-
9
actual_key = key.start_with?('@') ? key[1..-1] : key
-
-
9
attribute_defs[actual_key] = build_attribute_definition(actual_key, type)
-
end
-
-
# Wrap in component name
-
definition = {
-
4
@component_pascal_case => attribute_defs
-
}
-
-
# Write JSON file
-
4
File.write(file_path, JSON.pretty_generate(definition))
-
4
@logger.info "Created attribute definition file: #{file_path}"
-
end
-
-
# Map type string to JSON schema type (supports binding for all types)
-
# @param type [String] The type string from options
-
# @return [Array, String] JSON schema type(s) - array for binding support
-
1
def map_type_to_json_type(type)
-
19
case type.downcase
-
when 'string'
-
5
['string', 'binding']
-
when 'int', 'integer'
-
4
['number', 'binding']
-
when 'double', 'float'
-
2
['number', 'binding']
-
when 'bool', 'boolean'
-
3
['boolean', 'binding']
-
else
-
# Custom class types must use binding syntax (@{propertyName})
-
5
'binding'
-
end
-
end
-
-
1
def build_attribute_definition(actual_key, type)
-
{
-
9
"type" => map_type_to_json_type(type),
-
"description" => "#{actual_key} attribute"
-
}
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'fileutils'
-
1
require_relative '../../core/logger'
-
1
require_relative '../../core/config_manager'
-
1
require_relative '../../core/project_finder'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Generators
-
1
class DynamicComponentGenerator
-
1
def initialize(name, options = {})
-
40
@name = name
-
40
@component_name = name # PascalCase name
-
40
@class_name = "Dynamic#{name}Component"
-
40
@options = options
-
40
@logger = Core::Logger
-
end
-
-
1
def generate
-
create_dynamic_component_file
-
update_dynamic_registry
-
end
-
-
1
private
-
-
1
def create_dynamic_component_file
-
config = Core::ConfigManager.load_config
-
-
# Use config directory if available (where kjui.config.json was found)
-
base_path = config['_config_dir'] || Dir.pwd
-
source_directory = config['source_directory'] || 'src/main'
-
package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
-
-
# Create dynamic components directory in debug source set
-
dynamic_dir = File.join(
-
base_path,
-
source_directory.gsub('main', 'debug'), # Replace main with debug
-
'kotlin',
-
package_name.gsub('.', '/'),
-
'dynamic/components/extensions'
-
)
-
FileUtils.mkdir_p(dynamic_dir)
-
-
file_path = File.join(dynamic_dir, "#{@class_name}.kt")
-
-
if File.exist?(file_path)
-
@logger.warn "Dynamic component file already exists: #{file_path}"
-
print "Overwrite? (y/n): "
-
response = gets.chomp.downcase
-
return unless response == 'y'
-
end
-
-
File.write(file_path, dynamic_template)
-
@logger.info "Created dynamic component file: #{file_path}"
-
end
-
-
1
def update_dynamic_registry
-
config = Core::ConfigManager.load_config
-
-
# Use config directory if available (where kjui.config.json was found)
-
base_path = config['_config_dir'] || Dir.pwd
-
source_directory = config['source_directory'] || 'src/main'
-
package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
-
-
registry_file = File.join(
-
base_path,
-
source_directory.gsub('main', 'debug'), # Replace main with debug
-
'kotlin',
-
package_name.gsub('.', '/'),
-
'dynamic/DynamicComponentRegistry.kt'
-
)
-
-
if !File.exist?(registry_file)
-
create_initial_registry
-
return
-
end
-
-
# Read existing registry
-
content = File.read(registry_file)
-
-
# Check if component already registered
-
if content.include?("\"#{@component_name}\"")
-
@logger.warn "Component '#{@component_name}' already registered in DynamicComponentRegistry"
-
return
-
end
-
-
# Add new registration with proper indentation
-
new_registration = <<-REGISTRATION.chomp
-
"#{@component_name}" -> {
-
#{@class_name}.create(json, data)
-
true
-
}
-
REGISTRATION
-
-
# Insert before the else statement in when block
-
content.sub!(/(when \(type\) \{.*?)(\n else)/m) do
-
existing = $1
-
else_clause = $2
-
"#{existing}\n#{new_registration}#{else_clause}"
-
end
-
-
# Add import if not present
-
config = Core::ConfigManager.load_config
-
package_name = config['package_name'] || 'com.example.kotlinjsonui.sample'
-
import_line = "import #{package_name}.dynamic.components.extensions.#{@class_name}"
-
unless content.include?(import_line)
-
# Add import after the last import line
-
content.sub!(/(import .+\n)(\n)/) do
-
"#{$1}#{import_line}\n#{$2}"
-
end
-
end
-
-
File.write(registry_file, content)
-
@logger.info "Updated DynamicComponentRegistry with new component"
-
end
-
-
1
def create_initial_registry
-
config = Core::ConfigManager.load_config
-
-
# Use config directory if available (where kjui.config.json was found)
-
base_path = config['_config_dir'] || Dir.pwd
-
source_directory = config['source_directory'] || 'src/main'
-
package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
-
-
registry_dir = File.join(
-
base_path,
-
source_directory.gsub('main', 'debug'), # Replace main with debug
-
'kotlin',
-
package_name.gsub('.', '/'),
-
'dynamic'
-
)
-
FileUtils.mkdir_p(registry_dir)
-
-
registry_file = File.join(registry_dir, 'DynamicComponentRegistry.kt')
-
-
config = Core::ConfigManager.load_config
-
package_name = config['package_name'] || 'com.example.kotlinjsonui.sample'
-
-
content = <<~KOTLIN
-
package #{package_name}.dynamic
-
-
import androidx.compose.runtime.Composable
-
import com.google.gson.JsonObject
-
import #{package_name}.dynamic.components.extensions.#{@class_name}
-
-
/**
-
* Registry for dynamic custom components
-
* Auto-generated by kjui converter generator
-
*/
-
object DynamicComponentRegistry {
-
@Composable
-
fun createCustomComponent(
-
type: String,
-
json: JsonObject,
-
data: Map<String, Any>
-
): Boolean {
-
return when (type) {
-
"#{@component_name}" -> {
-
#{@class_name}.create(json, data)
-
true
-
}
-
else -> false
-
}
-
}
-
}
-
KOTLIN
-
-
File.write(registry_file, content)
-
@logger.info "Created DynamicComponentRegistry with initial component"
-
end
-
-
1
def dynamic_template
-
2
config = Core::ConfigManager.load_config
-
2
package_name = config['package_name'] || 'com.example.kotlinjsonui.sample'
-
-
# Determine if this is a container component
-
2
is_container = @options[:is_container]
-
-
2
<<~KOTLIN
-
package #{package_name}.dynamic.components.extensions
-
-
import androidx.compose.runtime.Composable
-
import androidx.compose.ui.Modifier
-
import com.google.gson.JsonObject
-
import com.google.gson.JsonElement
-
#{generate_dynamic_imports}
-
import com.kotlinjsonui.dynamic.helpers.ModifierBuilder
-
import #{package_name}.extensions.#{@component_name}
-
-
/**
-
* Dynamic wrapper for #{@component_name} component
-
* Auto-generated by kjui converter generator
-
*/
-
object #{@class_name} {
-
@Composable
-
fun create(
-
json: JsonObject,
-
data: Map<String, Any> = emptyMap()
-
) {
-
// Parse attributes
-
#{generate_dynamic_parameter_parsing}
-
-
// Build modifier
-
val modifier = ModifierBuilder.buildModifier(json)
-
-
#{if is_container
-
1
"// Call the custom component with children\n" +
-
" #{@component_name}(\n" +
-
generate_component_parameters +
-
" modifier = modifier\n" +
-
" ) {\n" +
-
" // Process children\n" +
-
" val children = json.get(\"children\")?.asJsonArray ?: json.get(\"child\")?.asJsonArray\n" +
-
" children?.forEach { childJson ->\n" +
-
" if (childJson.isJsonObject) {\n" +
-
" com.kotlinjsonui.dynamic.DynamicView(\n" +
-
" json = childJson.asJsonObject,\n" +
-
" data = data\n" +
-
" )\n" +
-
" }\n" +
-
" }\n" +
-
" }"
-
else
-
1
"// Call the custom component\n" +
-
" #{@component_name}(\n" +
-
generate_component_parameters +
-
" modifier = modifier\n" +
-
" )"
-
end}
-
}
-
-
#{generate_helper_methods}
-
}
-
KOTLIN
-
end
-
-
1
def generate_dynamic_imports
-
6
return "" if !@options[:attributes] || @options[:attributes].empty?
-
-
3
imports = []
-
3
@options[:attributes].each do |key, type|
-
3
case type.downcase
-
when 'alignment'
-
1
imports << "import androidx.compose.ui.Alignment"
-
when 'text', 'string'
-
1
imports << "import androidx.compose.ui.text.style.TextAlign"
-
when 'color'
-
1
imports << "import androidx.compose.ui.graphics.Color"
-
end
-
end
-
-
3
imports.uniq.join("\n")
-
end
-
-
1
def generate_attribute_docs
-
3
return " * - child/children: Array of child components" if !@options[:attributes] || @options[:attributes].empty?
-
-
2
docs = [" * - child/children: Array of child components"]
-
2
@options[:attributes].each do |key, type|
-
2
is_binding = key.start_with?('@')
-
2
actual_key = is_binding ? key[1..-1] : key
-
2
binding_note = is_binding ? " (supports @{binding})" : ""
-
2
docs << " * - #{actual_key}: #{type}#{binding_note}"
-
end
-
-
2
docs.join("\n")
-
end
-
-
1
def generate_dynamic_parameter_parsing
-
4
return "" if !@options[:attributes] || @options[:attributes].empty?
-
-
1
lines = []
-
1
@options[:attributes].each do |key, type|
-
1
is_binding = key.start_with?('@')
-
1
actual_key = is_binding ? key[1..-1] : key
-
-
1
method_name = get_parser_method_name(type)
-
1
lines << " val #{actual_key} = #{method_name}(json.get(\"#{actual_key}\"), data)"
-
end
-
-
1
lines.join("\n")
-
end
-
-
1
def get_parser_method_name(type)
-
10
case type.downcase
-
when 'string', 'text'
-
3
'parseString'
-
when 'int', 'integer'
-
2
'parseInt'
-
when 'bool', 'boolean'
-
2
'parseBoolean'
-
when 'color'
-
1
'parseColor'
-
when 'float', 'double'
-
1
'parseFloat'
-
else
-
1
'parseString'
-
end
-
end
-
-
1
def generate_component_parameters
-
4
return "" if !@options[:attributes] || @options[:attributes].empty?
-
-
1
lines = []
-
1
@options[:attributes].each do |key, type|
-
1
is_binding = key.start_with?('@')
-
1
actual_key = is_binding ? key[1..-1] : key
-
-
# Generate parameter with null safety
-
1
lines << " #{actual_key} = #{actual_key} ?: #{get_default_value(type)},"
-
end
-
-
1
lines.join("\n") + "\n"
-
end
-
-
1
def get_default_value(type)
-
7
case type.downcase
-
when 'string', 'text'
-
2
'""'
-
when 'int', 'integer'
-
1
'0'
-
when 'bool', 'boolean'
-
1
'false'
-
when 'float', 'double'
-
1
'0.0'
-
when 'color'
-
1
'androidx.compose.ui.graphics.Color.Unspecified'
-
else
-
1
'null'
-
end
-
end
-
-
1
def generate_helper_methods
-
7
return "" if !@options[:attributes] || @options[:attributes].empty?
-
-
4
methods = []
-
4
types_added = []
-
-
4
@options[:attributes].each do |key, type|
-
4
next if types_added.include?(type.downcase)
-
4
types_added << type.downcase
-
-
4
case type.downcase
-
when 'string', 'text'
-
1
methods << string_parser_method
-
when 'int', 'integer'
-
1
methods << int_parser_method
-
when 'bool', 'boolean'
-
1
methods << bool_parser_method
-
when 'color'
-
1
methods << color_parser_method
-
end
-
end
-
-
4
methods.join("\n\n")
-
end
-
-
1
def string_parser_method
-
1
<<~KOTLIN
-
private fun parseString(element: com.google.gson.JsonElement?, data: Map<String, Any>): String? {
-
if (element == null || element.isJsonNull) return null
-
-
val value = element.asString
-
-
// Check for binding
-
if (value.startsWith("@{") && value.endsWith("}")) {
-
val propertyName = value.substring(2, value.length - 1)
-
return data[propertyName]?.toString()
-
}
-
-
return value
-
}
-
KOTLIN
-
end
-
-
1
def int_parser_method
-
1
<<~KOTLIN
-
private fun parseInt(element: com.google.gson.JsonElement?, data: Map<String, Any>): Int? {
-
if (element == null || element.isJsonNull) return null
-
-
if (element.isJsonPrimitive) {
-
val primitive = element.asJsonPrimitive
-
if (primitive.isNumber) {
-
return primitive.asInt
-
} else if (primitive.isString) {
-
val value = primitive.asString
-
// Check for binding
-
if (value.startsWith("@{") && value.endsWith("}")) {
-
val propertyName = value.substring(2, value.length - 1)
-
return (data[propertyName] as? Number)?.toInt()
-
}
-
return value.toIntOrNull()
-
}
-
}
-
-
return null
-
}
-
KOTLIN
-
end
-
-
1
def bool_parser_method
-
1
<<~KOTLIN
-
private fun parseBoolean(element: com.google.gson.JsonElement?, data: Map<String, Any>): Boolean? {
-
if (element == null || element.isJsonNull) return null
-
-
if (element.isJsonPrimitive) {
-
val primitive = element.asJsonPrimitive
-
if (primitive.isBoolean) {
-
return primitive.asBoolean
-
} else if (primitive.isString) {
-
val value = primitive.asString
-
// Check for binding
-
if (value.startsWith("@{") && value.endsWith("}")) {
-
val propertyName = value.substring(2, value.length - 1)
-
return data[propertyName] as? Boolean
-
}
-
return value.toBooleanStrictOrNull()
-
}
-
}
-
-
return null
-
}
-
KOTLIN
-
end
-
-
1
def color_parser_method
-
1
<<~KOTLIN
-
private fun parseColor(element: com.google.gson.JsonElement?, data: Map<String, Any>): Color? {
-
if (element == null || element.isJsonNull) return null
-
-
if (element.isJsonPrimitive && element.asJsonPrimitive.isString) {
-
val value = element.asString
-
-
// Check for binding
-
if (value.startsWith("@{") && value.endsWith("}")) {
-
val propertyName = value.substring(2, value.length - 1)
-
val boundValue = data[propertyName]?.toString()
-
return boundValue?.let { parseColorString(it) }
-
}
-
-
return parseColorString(value)
-
}
-
-
return null
-
}
-
-
private fun parseColorString(value: String): Color? {
-
return if (value.startsWith("#")) {
-
try {
-
Color(android.graphics.Color.parseColor(value))
-
} catch (e: Exception) {
-
null
-
}
-
} else {
-
null
-
}
-
}
-
KOTLIN
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'fileutils'
-
1
require_relative '../../core/logger'
-
1
require_relative '../../core/config_manager'
-
1
require_relative '../../core/project_finder'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Generators
-
1
class KotlinComponentGenerator
-
1
def initialize(name, options = {})
-
43
@name = name
-
43
@component_name = name # PascalCase name
-
43
@package_name = get_package_name
-
43
@options = options
-
43
@logger = Core::Logger
-
end
-
-
1
def generate
-
create_kotlin_file
-
end
-
-
1
private
-
-
1
def create_kotlin_file
-
config = Core::ConfigManager.load_config
-
-
# Use config directory if available (where kjui.config.json was found)
-
base_path = config['_config_dir'] || Dir.pwd
-
source_directory = config['source_directory'] || 'src/main'
-
package_name = config['package_name'] || Core::ProjectFinder.get_package_name || 'com.example.kotlinjsonui.sample'
-
-
# Get extension directory from config
-
extension_directory = config['extension_directory'] || "kotlin/#{package_name.gsub('.', '/')}/extensions"
-
-
# Build extension directory path
-
extension_dir = File.join(
-
base_path,
-
source_directory,
-
extension_directory
-
)
-
-
FileUtils.mkdir_p(extension_dir)
-
-
kotlin_file_path = File.join(extension_dir, "#{@component_name}.kt")
-
-
if File.exist?(kotlin_file_path)
-
@logger.warn "Kotlin file already exists: #{kotlin_file_path}"
-
print "Overwrite? (y/n): "
-
response = gets.chomp.downcase
-
return unless response == 'y'
-
end
-
-
File.write(kotlin_file_path, kotlin_template)
-
@logger.info "Created Kotlin file: #{kotlin_file_path}"
-
end
-
-
1
def get_package_name
-
44
config = Core::ConfigManager.load_config
-
44
base_package = config['package_name'] || 'com.example.kotlinjsonui.sample'
-
44
"#{base_package}.extensions"
-
end
-
-
1
def kotlin_template
-
2
if @options[:is_container] != false
-
1
container_template
-
else
-
1
non_container_template
-
end
-
end
-
-
1
def container_template
-
3
imports = generate_kotlin_imports
-
3
params = generate_kotlin_parameters
-
-
3
template = <<~KOTLIN
-
package #{@package_name}
-
-
import androidx.compose.foundation.layout.Box
-
import androidx.compose.foundation.layout.BoxScope
-
import androidx.compose.runtime.Composable
-
import androidx.compose.ui.Modifier
-
KOTLIN
-
-
3
template += imports + "\n" if !imports.empty?
-
3
template += "\n"
-
-
3
template += <<~KOTLIN
-
/**
-
* Custom #{@component_name} component
-
* Generated by kjui converter generator
-
*
-
* Regenerate with:
-
* kjui g converter #{@component_name} --container#{format_attributes_for_command}
-
*/
-
@Composable
-
fun #{@component_name}(
-
KOTLIN
-
-
3
if !params.empty?
-
template += params
-
end
-
-
3
template += <<~KOTLIN
-
modifier: Modifier = Modifier,
-
content: @Composable BoxScope.() -> Unit
-
) {
-
Box(
-
modifier = modifier
-
) {
-
// Custom container implementation
-
content()
-
}
-
}
-
KOTLIN
-
-
3
template
-
end
-
-
1
def non_container_template
-
2
imports = generate_kotlin_imports
-
2
params = generate_kotlin_parameters
-
-
2
template = <<~KOTLIN
-
package #{@package_name}
-
-
import androidx.compose.foundation.layout.Box
-
import androidx.compose.runtime.Composable
-
import androidx.compose.ui.Modifier
-
KOTLIN
-
-
2
template += imports + "\n" if !imports.empty?
-
2
template += "\n"
-
-
2
template += <<~KOTLIN
-
/**
-
* Custom #{@component_name} component
-
* Generated by kjui converter generator
-
*
-
* Regenerate with:
-
* kjui g converter #{@component_name} --no-container#{format_attributes_for_command}
-
*/
-
@Composable
-
fun #{@component_name}(
-
KOTLIN
-
-
2
if !params.empty?
-
template += params
-
end
-
-
2
template += <<~KOTLIN
-
modifier: Modifier = Modifier
-
) {
-
// TODO: Implement your custom component
-
Box(modifier = modifier) {
-
// Component content
-
}
-
}
-
KOTLIN
-
-
2
template
-
end
-
-
1
def generate_kotlin_imports
-
9
return "" if !@options[:attributes] || @options[:attributes].empty?
-
-
3
imports = []
-
3
@options[:attributes].each do |key, type|
-
3
case type.downcase
-
when 'color'
-
1
imports << "import androidx.compose.ui.graphics.Color"
-
when 'dp', 'size'
-
1
imports << "import androidx.compose.ui.unit.dp"
-
1
imports << "import androidx.compose.ui.unit.Dp"
-
when 'alignment'
-
1
imports << "import androidx.compose.ui.Alignment"
-
when 'text', 'string'
-
# No special import needed
-
when 'int', 'float', 'double'
-
# No special import needed
-
when 'boolean', 'bool'
-
# No special import needed
-
end
-
end
-
-
3
imports.uniq.join("\n")
-
end
-
-
1
def generate_kotlin_parameters
-
7
return "" if !@options[:attributes] || @options[:attributes].empty?
-
-
1
params = []
-
1
@options[:attributes].each do |key, type|
-
1
is_binding = key.start_with?('@')
-
1
actual_key = is_binding ? key[1..-1] : key
-
1
kotlin_type = map_type_to_kotlin(type)
-
-
1
default_value = get_default_value(type)
-
1
params << " #{actual_key}: #{kotlin_type}#{default_value},"
-
end
-
-
1
params.join("\n") + "\n"
-
end
-
-
-
1
def map_type_to_kotlin(type)
-
14
case type.downcase
-
when 'string', 'text'
-
3
'String'
-
when 'int', 'integer'
-
2
'Int'
-
when 'float'
-
1
'Float'
-
when 'double'
-
1
'Double'
-
when 'bool', 'boolean'
-
2
'Boolean'
-
when 'color'
-
1
'Color'
-
when 'dp', 'size'
-
2
'Dp'
-
when 'alignment'
-
1
'Alignment'
-
else
-
1
'Any'
-
end
-
end
-
-
1
def get_default_value(type)
-
10
case type.downcase
-
when 'string', 'text'
-
2
' = ""'
-
when 'int', 'integer'
-
1
' = 0'
-
when 'float'
-
1
' = 0f'
-
when 'double'
-
1
' = 0.0'
-
when 'bool', 'boolean'
-
1
' = false'
-
when 'color'
-
1
' = Color.Unspecified'
-
when 'dp', 'size'
-
1
' = 0.dp'
-
when 'alignment'
-
1
' = Alignment.TopStart'
-
else
-
1
' = null'
-
end
-
end
-
-
1
def format_attributes_for_command
-
7
return "" if !@options[:attributes] || @options[:attributes].empty?
-
-
1
attrs = @options[:attributes].map do |key, type|
-
2
" --attr #{key}:#{type}"
-
end.join("")
-
-
1
attrs
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'json'
-
1
require 'fileutils'
-
1
require_relative '../../core/config_manager'
-
1
require_relative '../../core/project_finder'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Generators
-
1
class ViewGenerator
-
1
def initialize(name, options = {})
-
19
@name = name
-
19
@options = options
-
19
@config = Core::ConfigManager.load_config
-
end
-
-
1
def generate
-
# Parse name for subdirectories
-
17
parts = @name.split('/')
-
17
view_name = parts.last
-
17
subdirectory = parts[0...-1].join('/') if parts.length > 1
-
-
# Convert to proper case
-
17
view_class_name = to_pascal_case(view_name)
-
17
json_file_name = to_snake_case(view_name)
-
-
# Get directories from config
-
17
source_dir = @config['source_directory'] || 'src/main'
-
17
layouts_dir = @config['layouts_directory'] || 'assets/Layouts'
-
17
view_dir = @config['view_directory'] || 'kotlin/com/example/kotlinjsonui/sample/views'
-
17
viewmodel_dir = @config['viewmodel_directory'] || 'kotlin/com/example/kotlinjsonui/sample/viewmodels'
-
17
data_dir = @config['data_directory'] || 'kotlin/com/example/kotlinjsonui/sample/data'
-
17
package_name = @config['package_name'] || 'com.example.kotlinjsonui.sample'
-
-
# Create full paths with subdirectory support
-
# Each view gets its own directory (using snake_case for Android)
-
17
view_folder_name = to_snake_case(view_name)
-
-
17
if subdirectory
-
1
json_path = File.join(source_dir, layouts_dir, subdirectory)
-
1
swift_path = File.join(source_dir, view_dir, subdirectory, view_folder_name)
-
1
viewmodel_path = File.join(source_dir, viewmodel_dir, subdirectory)
-
1
data_path = File.join(source_dir, data_dir, subdirectory)
-
else
-
16
json_path = File.join(source_dir, layouts_dir)
-
# Create a folder for each view (e.g., views/home_view/ for HomeView)
-
16
swift_path = File.join(source_dir, view_dir, view_folder_name)
-
16
viewmodel_path = File.join(source_dir, viewmodel_dir)
-
16
data_path = File.join(source_dir, data_dir)
-
end
-
-
# Create directories if they don't exist
-
17
FileUtils.mkdir_p(json_path)
-
17
FileUtils.mkdir_p(swift_path)
-
17
FileUtils.mkdir_p(viewmodel_path)
-
17
FileUtils.mkdir_p(data_path)
-
-
# Create JSON file
-
17
json_file = File.join(json_path, "#{json_file_name}.json")
-
17
create_json_template(json_file, view_class_name)
-
-
# Create Main View file (add View suffix to class name)
-
17
main_kotlin_file = File.join(swift_path, "#{view_class_name}View.kt")
-
17
create_main_view_template(main_kotlin_file, view_class_name, json_file_name, subdirectory, package_name)
-
-
# Create Generated View file
-
17
generated_kotlin_file = File.join(swift_path, "#{view_class_name}GeneratedView.kt")
-
17
create_generated_view_template(generated_kotlin_file, view_class_name, json_file_name, subdirectory, package_name)
-
-
# Create Data file
-
17
data_file = File.join(data_path, "#{view_class_name}Data.kt")
-
17
create_data_template(data_file, view_class_name, package_name)
-
-
# Create ViewModel file
-
17
viewmodel_file = File.join(viewmodel_path, "#{view_class_name}ViewModel.kt")
-
17
create_viewmodel_template(viewmodel_file, view_class_name, json_file_name, subdirectory, package_name)
-
-
# Update MainActivity if --root option is specified
-
17
if @options[:root]
-
update_main_activity(view_class_name, package_name)
-
end
-
-
17
puts "Generated Compose view:"
-
17
puts " JSON: #{json_file}"
-
17
puts " Main View: #{main_kotlin_file}"
-
17
puts " Generated View: #{generated_kotlin_file}"
-
17
puts " Data: #{data_file}"
-
17
puts " ViewModel: #{viewmodel_file}"
-
-
17
if @options[:root]
-
puts " Updated MainActivity to use #{view_class_name}View as root"
-
end
-
-
17
puts ""
-
17
puts "Next steps:"
-
17
puts " 1. Edit the JSON layout in #{json_file}"
-
17
puts " 2. Run 'kjui build' to generate the Compose code"
-
end
-
-
1
private
-
-
1
def to_pascal_case(str)
-
# Handle camelCase and PascalCase input
-
# First convert to snake_case, then to PascalCase
-
17
snake = str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
-
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
-
.downcase
-
17
snake.split(/[_\-]/).map(&:capitalize).join
-
end
-
-
1
def to_snake_case(str)
-
68
str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
-
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
-
.downcase
-
end
-
-
1
def create_json_template(file_path, view_name)
-
17
return if File.exist?(file_path)
-
-
template = {
-
16
type: "SafeAreaView",
-
background: "#FFFFFF",
-
child: [
-
{
-
type: "View",
-
orientation: "vertical",
-
padding: 16,
-
child: [
-
{
-
type: "Label",
-
text: "@{title}",
-
fontSize: 24,
-
fontWeight: "bold",
-
fontColor: "#000000",
-
marginBottom: 20
-
},
-
{
-
type: "Label",
-
text: "Welcome to #{view_name}",
-
fontSize: 16,
-
fontColor: "#666666",
-
marginBottom: 30
-
},
-
{
-
type: "Button",
-
text: "Get Started",
-
onclick: "onGetStarted",
-
background: "#6200EE",
-
fontColor: "#FFFFFF",
-
padding: [12, 24],
-
cornerRadius: 8
-
}
-
]
-
}
-
],
-
data: [
-
{
-
name: "title",
-
class: "String",
-
defaultValue: "'#{view_name}'"
-
}
-
]
-
}
-
-
16
File.write(file_path, JSON.pretty_generate(template))
-
16
puts "Created JSON template: #{file_path}"
-
end
-
-
1
def create_main_view_template(file_path, view_name, json_name, subdirectory, package_name)
-
17
return if File.exist?(file_path)
-
-
17
package_parts = package_name.split('.')
-
# Each view has its own package (e.g., com.example.views.home_view)
-
17
view_folder_name = to_snake_case(view_name)
-
17
view_package = subdirectory ? "#{package_name}.views.#{subdirectory.gsub('/', '.')}.#{view_folder_name}" : "#{package_name}.views.#{view_folder_name}"
-
-
17
template = <<~KOTLIN
-
package #{view_package}
-
-
import androidx.compose.runtime.Composable
-
import androidx.compose.runtime.collectAsState
-
import androidx.compose.runtime.getValue
-
import androidx.lifecycle.viewmodel.compose.viewModel
-
import #{package_name}.viewmodels.#{view_name}ViewModel
-
-
@Composable
-
fun #{view_name}View(
-
viewModel: #{view_name}ViewModel = viewModel()
-
) {
-
val data by viewModel.data.collectAsState()
-
-
#{view_name}GeneratedView(data = data)
-
}
-
KOTLIN
-
-
17
File.write(file_path, template)
-
17
puts "Created Main View template: #{file_path}"
-
end
-
-
1
def create_generated_view_template(file_path, view_name, json_name, subdirectory, package_name)
-
17
return if File.exist?(file_path)
-
-
17
json_reference = subdirectory ? "#{subdirectory}/#{json_name}" : json_name
-
# Each view has its own package (using snake_case for folder)
-
17
view_folder_name = to_snake_case(view_name)
-
17
view_package = subdirectory ? "#{package_name}.views.#{subdirectory.gsub('/', '.')}.#{view_folder_name}" : "#{package_name}.views.#{view_folder_name}"
-
-
17
template = <<~KOTLIN
-
package #{view_package}
-
-
import androidx.compose.foundation.background
-
import androidx.compose.foundation.layout.*
-
import androidx.compose.foundation.lazy.LazyColumn
-
import androidx.compose.foundation.lazy.LazyRow
-
import androidx.compose.material3.*
-
import androidx.compose.runtime.Composable
-
import androidx.compose.ui.Alignment
-
import androidx.compose.ui.Modifier
-
import androidx.compose.ui.graphics.Color
-
import androidx.compose.ui.text.font.FontWeight
-
import androidx.compose.ui.text.style.TextAlign
-
import androidx.compose.ui.unit.dp
-
import androidx.compose.ui.unit.sp
-
import #{package_name}.data.#{view_name}Data
-
-
@Composable
-
fun #{view_name}GeneratedView(
-
data: #{view_name}Data
-
) {
-
// Generated Compose code from #{json_reference}.json
-
// This will be updated when you run 'kjui build'
-
// >>> GENERATED_CODE_START
-
Column(
-
modifier = Modifier
-
.fillMaxSize()
-
.padding(16.dp),
-
horizontalAlignment = Alignment.CenterHorizontally
-
) {
-
Text(
-
text = data.title,
-
fontSize = 24.sp,
-
fontWeight = FontWeight.Bold
-
)
-
-
Spacer(modifier = Modifier.height(16.dp))
-
-
Text(
-
text = "Run 'kjui build' to generate Compose code",
-
fontSize = 14.sp,
-
color = Color.Gray
-
)
-
}
-
// >>> GENERATED_CODE_END
-
}
-
KOTLIN
-
-
17
File.write(file_path, template)
-
17
puts "Created Generated View template: #{file_path}"
-
end
-
-
1
def create_data_template(file_path, view_name, package_name)
-
17
return if File.exist?(file_path)
-
-
17
data_package = "#{package_name}.data"
-
-
17
template = <<~KOTLIN
-
package #{data_package}
-
-
data class #{view_name}Data(
-
var title: String = "#{view_name}",
-
-
// Action closures (called from generated views)
-
var onGetStarted: (() -> Unit)? = null
-
// Add more data properties as needed based on your JSON structure
-
) {
-
// Update properties from map
-
fun update(map: Map<String, Any>) {
-
map["title"]?.let {
-
if (it is String) title = it
-
}
-
}
-
-
// Convert to map for dynamic mode
-
fun toMap(): Map<String, Any> {
-
return mutableMapOf(
-
"title" to title
-
)
-
}
-
}
-
KOTLIN
-
-
17
File.write(file_path, template)
-
17
puts "Created Data template: #{file_path}"
-
end
-
-
1
def create_viewmodel_template(file_path, view_name, json_name, subdirectory, package_name)
-
17
return if File.exist?(file_path)
-
-
17
json_reference = subdirectory ? "#{subdirectory}/#{json_name}" : json_name
-
17
viewmodel_package = "#{package_name}.viewmodels"
-
-
17
template = <<~KOTLIN
-
package #{viewmodel_package}
-
-
import android.app.Application
-
import androidx.lifecycle.AndroidViewModel
-
import kotlinx.coroutines.flow.MutableStateFlow
-
import kotlinx.coroutines.flow.StateFlow
-
import kotlinx.coroutines.flow.asStateFlow
-
import #{package_name}.data.#{view_name}Data
-
-
class #{view_name}ViewModel(application: Application) : AndroidViewModel(application) {
-
// JSON file reference for hot reload
-
val jsonFileName = "#{json_reference}"
-
-
// Data model
-
private val _data = MutableStateFlow(#{view_name}Data())
-
val data: StateFlow<#{view_name}Data> = _data.asStateFlow()
-
-
// Action handlers
-
fun onGetStarted() {
-
// Handle button tap
-
}
-
-
// Add more action handlers as needed
-
fun updateData(updates: Map<String, Any>) {
-
_data.value.update(updates)
-
_data.value = _data.value.copy() // Trigger recomposition
-
}
-
}
-
KOTLIN
-
-
17
File.write(file_path, template)
-
17
puts "Created ViewModel template: #{file_path}"
-
end
-
-
1
def update_main_activity(view_name, package_name)
-
source_dir = @config['source_directory'] || 'src/main'
-
-
# Find MainActivity file
-
activity_files = Dir.glob(File.join(source_dir, '**/MainActivity.kt'))
-
if activity_files.empty?
-
puts "Warning: Could not find MainActivity.kt file to update"
-
return
-
end
-
-
activity_file = activity_files.first
-
content = File.read(activity_file)
-
-
# Add import for the new view (view is in its own package with snake_case folder)
-
view_folder_name = to_snake_case(view_name)
-
import_line = "import #{package_name}.views.#{view_folder_name}.#{view_name}View"
-
unless content.include?(import_line)
-
# Find the last import line and add after it
-
if content =~ /^((?:.*\nimport .*\n)+)/m
-
imports_block = $1
-
# Add the new import after the last import
-
new_imports = imports_block.chomp + "\n#{import_line}\n"
-
content.sub!(imports_block, new_imports)
-
end
-
end
-
-
# Update setContent - look for the pattern and replace the content inside
-
updated = false
-
-
# Pattern 1: Full setContent block with theme
-
if content =~ /setContent\s*\{[\s\S]*?\n\s{8}\}/m
-
content.gsub!(/setContent\s*\{[\s\S]*?\n\s{8}\}/m) do
-
<<~KOTLIN.chomp
-
setContent {
-
KotlinJsonUITheme {
-
Surface(
-
modifier = Modifier.fillMaxSize(),
-
color = MaterialTheme.colorScheme.background
-
) {
-
#{view_name}View()
-
}
-
}
-
}
-
KOTLIN
-
end
-
updated = true
-
# Pattern 2: Simple setContent
-
elsif content =~ /setContent\s*\{[^}]*\}/m
-
content.gsub!(/setContent\s*\{[^}]*\}/m) do
-
<<~KOTLIN.chomp
-
setContent {
-
#{view_name}View()
-
}
-
KOTLIN
-
end
-
updated = true
-
end
-
-
if updated
-
File.write(activity_file, content)
-
puts "Updated MainActivity to use #{view_name}View as root"
-
else
-
puts "Warning: Could not update MainActivity automatically"
-
puts "Please manually update your MainActivity to use #{view_name}View()"
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Helpers
-
1
class ImportManager
-
1
def self.get_imports_map(package_name = nil)
-
# Use provided package name or default to sample app
-
15
pkg_name = package_name || 'com.example.kotlinjsonui.sample'
-
-
{
-
15
lazy_column: "import androidx.compose.foundation.lazy.LazyColumn",
-
lazy_row: "import androidx.compose.foundation.lazy.LazyRow",
-
background: "import androidx.compose.foundation.background",
-
border: "import androidx.compose.foundation.border",
-
shape: ["import androidx.compose.foundation.shape.RoundedCornerShape",
-
"import androidx.compose.ui.draw.clip"],
-
text_align: "import androidx.compose.ui.text.style.TextAlign",
-
text_overflow: "import androidx.compose.ui.text.style.TextOverflow",
-
text_style: "import androidx.compose.ui.text.TextStyle",
-
visual_transformation: "import androidx.compose.ui.text.input.PasswordVisualTransformation",
-
shadow: "import androidx.compose.ui.draw.shadow",
-
arrangement: "import androidx.compose.foundation.layout.Arrangement",
-
keyboard_type: ["import androidx.compose.foundation.text.KeyboardOptions",
-
"import androidx.compose.ui.text.input.KeyboardType"],
-
ime_action: "import androidx.compose.ui.text.input.ImeAction",
-
ime_padding: "import androidx.compose.foundation.layout.imePadding",
-
button_colors: "import androidx.compose.material3.ButtonDefaults",
-
button_padding: "import androidx.compose.foundation.layout.PaddingValues",
-
padding_values: "import androidx.compose.foundation.layout.PaddingValues",
-
text_decoration: "import androidx.compose.ui.text.style.TextDecoration",
-
shadow_style: ["import androidx.compose.ui.text.TextStyle",
-
"import androidx.compose.ui.graphics.Shadow",
-
"import androidx.compose.ui.geometry.Offset"],
-
switch_colors: "import androidx.compose.material3.SwitchDefaults",
-
slider_colors: "import androidx.compose.material3.SliderDefaults",
-
checkbox_colors: "import androidx.compose.material3.CheckboxDefaults",
-
dropdown_menu: ["import androidx.compose.material3.DropdownMenu",
-
"import androidx.compose.material3.DropdownMenuItem",
-
"import androidx.compose.material.icons.Icons",
-
"import androidx.compose.material.icons.filled.ArrowDropDown",
-
"import androidx.compose.foundation.clickable"],
-
outlined_text_field: "import androidx.compose.material3.OutlinedTextField",
-
icons: ["import androidx.compose.material.icons.Icons",
-
"import androidx.compose.material.icons.filled.*",
-
"import androidx.compose.material.icons.outlined.*"],
-
icon_button: "import androidx.compose.material3.IconButton",
-
clickable: "import androidx.compose.foundation.clickable",
-
radio_colors: "import androidx.compose.material3.RadioButtonDefaults",
-
tab_row: ["import androidx.compose.material3.TabRow",
-
"import androidx.compose.material3.Tab"],
-
async_image: "import coil.compose.AsyncImage",
-
content_scale: "import androidx.compose.ui.layout.ContentScale",
-
lazy_grid: ["import androidx.compose.foundation.lazy.grid.LazyVerticalGrid",
-
"import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid",
-
"import androidx.compose.foundation.lazy.grid.GridCells"],
-
grid_item_span: "import androidx.compose.foundation.lazy.grid.GridItemSpan",
-
webview: ["import android.webkit.WebView",
-
"import android.webkit.WebViewClient",
-
"import android.webkit.WebChromeClient",
-
"import androidx.compose.ui.viewinterop.AndroidView"],
-
constraint_layout: ["import androidx.constraintlayout.compose.ConstraintLayout",
-
"import androidx.constraintlayout.compose.Dimension"],
-
remember_state: ["import androidx.compose.runtime.remember",
-
"import androidx.compose.runtime.mutableStateOf",
-
"import androidx.compose.runtime.getValue",
-
"import androidx.compose.runtime.setValue"],
-
remember: "import androidx.compose.runtime.remember",
-
LaunchedEffect: "import androidx.compose.runtime.LaunchedEffect",
-
launched_effect: "import androidx.compose.runtime.LaunchedEffect",
-
disposable_effect: "import androidx.compose.runtime.DisposableEffect",
-
bias_alignment: "import androidx.compose.ui.BiasAlignment",
-
circle_shape: "import androidx.compose.foundation.shape.CircleShape",
-
alpha: "import androidx.compose.ui.draw.alpha",
-
image: "import androidx.compose.foundation.Image",
-
painter_resource: "import androidx.compose.ui.res.painterResource",
-
string_resource: "import androidx.compose.ui.res.stringResource",
-
color_resource: "import androidx.compose.ui.res.colorResource",
-
r_class: "import #{pkg_name}.R",
-
gradient: "import androidx.compose.ui.graphics.Brush",
-
blur: "import androidx.compose.ui.draw.blur",
-
navigation: ["import androidx.navigation.NavController",
-
"import androidx.navigation.compose.NavHost",
-
"import androidx.navigation.compose.composable",
-
"import androidx.navigation.compose.rememberNavController"],
-
selectbox_component: "import com.kotlinjsonui.components.SelectBox",
-
date_selectbox_component: "import com.kotlinjsonui.components.DateSelectBox",
-
simple_date_selectbox_component: "import com.kotlinjsonui.components.SimpleDateSelectBox",
-
visibility_wrapper: "import com.kotlinjsonui.components.VisibilityWrapper",
-
custom_textfield: ["import com.kotlinjsonui.components.CustomTextField",
-
"import com.kotlinjsonui.components.CustomTextFieldWithMargins"],
-
annotated_string: ["import androidx.compose.ui.text.AnnotatedString",
-
"import androidx.compose.ui.text.buildAnnotatedString",
-
"import androidx.compose.ui.text.SpanStyle",
-
"import androidx.compose.ui.text.withStyle"],
-
clickable_text: "import androidx.compose.foundation.text.ClickableText",
-
partial_attributes_text: ["import com.kotlinjsonui.components.PartialAttributesText",
-
"import com.kotlinjsonui.components.PartialAttribute"],
-
segment: "import com.kotlinjsonui.components.Segment",
-
dynamic_mode_manager: "import com.kotlinjsonui.core.DynamicModeManager",
-
configuration: "import com.kotlinjsonui.core.Configuration",
-
safe_dynamic_view: "import com.kotlinjsonui.components.SafeDynamicView",
-
circular_progress_indicator: "import androidx.compose.material3.CircularProgressIndicator",
-
wrapContentSize: "import androidx.compose.foundation.layout.wrapContentSize",
-
box: "import androidx.compose.foundation.layout.Box",
-
DynamicView: "import com.kotlinjsonui.dynamic.DynamicView",
-
JsonObject: "import com.google.gson.JsonObject",
-
JsonParser: "import com.google.gson.JsonParser",
-
dashed_border: ["import com.kotlinjsonui.dynamic.helpers.dashedBorder",
-
"import com.kotlinjsonui.dynamic.helpers.dottedBorder"]
-
}
-
end
-
-
1
def self.update_imports(content, required_imports)
-
6
imports_map = get_imports_map
-
-
6
required_imports.each do |import_key|
-
6
import_lines = imports_map[import_key]
-
6
next unless import_lines
-
-
5
if import_lines.is_a?(Array)
-
1
import_lines.each do |import_line|
-
2
unless content.include?(import_line)
-
# Add import after the last import statement
-
2
if content =~ /^(import .+\n)+/m
-
2
last_import_end = $~.end(0)
-
2
content.insert(last_import_end, "#{import_line}\n")
-
end
-
end
-
end
-
else
-
4
unless content.include?(import_lines)
-
# Add import after the last import statement
-
3
if content =~ /^(import .+\n)+/m
-
3
last_import_end = $~.end(0)
-
3
content.insert(last_import_end, "#{import_lines}\n")
-
end
-
end
-
end
-
end
-
-
6
content
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require_relative 'resource_resolver'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Helpers
-
# Helper class to build Compose modifiers from JSON attributes
-
1
class ModifierBuilder
-
1
def self.build_padding(json_data)
-
412
modifiers = []
-
-
# Handle padding attribute (can be array [top, right, bottom, left] or single value)
-
412
if json_data['padding']
-
9
if json_data['padding'].is_a?(Array)
-
1
pad_values = json_data['padding']
-
1
if pad_values.length == 4
-
1
modifiers << ".padding(top = #{pad_values[0]}.dp, end = #{pad_values[1]}.dp, bottom = #{pad_values[2]}.dp, start = #{pad_values[3]}.dp)"
-
elsif pad_values.length == 1
-
modifiers << ".padding(#{pad_values[0]}.dp)"
-
end
-
else
-
8
modifiers << ".padding(#{json_data['padding']}.dp)"
-
end
-
end
-
-
# Handle paddings attribute (same as padding)
-
412
if json_data['paddings']
-
1
if json_data['paddings'].is_a?(Array)
-
pad_values = json_data['paddings']
-
if pad_values.length == 4
-
modifiers << ".padding(top = #{pad_values[0]}.dp, end = #{pad_values[1]}.dp, bottom = #{pad_values[2]}.dp, start = #{pad_values[3]}.dp)"
-
elsif pad_values.length == 1
-
modifiers << ".padding(#{pad_values[0]}.dp)"
-
end
-
else
-
1
modifiers << ".padding(#{json_data['paddings']}.dp)"
-
end
-
end
-
-
# Individual padding attributes
-
412
modifiers << ".padding(top = #{json_data['paddingTop']}.dp)" if json_data['paddingTop']
-
412
modifiers << ".padding(bottom = #{json_data['paddingBottom']}.dp)" if json_data['paddingBottom']
-
412
modifiers << ".padding(start = #{json_data['paddingLeft']}.dp)" if json_data['paddingLeft']
-
412
modifiers << ".padding(end = #{json_data['paddingRight']}.dp)" if json_data['paddingRight']
-
-
412
modifiers
-
end
-
-
1
def self.build_margins(json_data)
-
487
modifiers = []
-
-
# Handle margins attribute (can be array [top, right, bottom, left] or single value)
-
487
if json_data['margins']
-
16
if json_data['margins'].is_a?(Array)
-
15
margin_values = json_data['margins']
-
15
if margin_values.length == 4
-
5
modifiers << ".padding(top = #{margin_values[0]}.dp, end = #{margin_values[1]}.dp, bottom = #{margin_values[2]}.dp, start = #{margin_values[3]}.dp)"
-
10
elsif margin_values.length == 1
-
5
modifiers << ".padding(#{margin_values[0]}.dp)"
-
end
-
else
-
1
modifiers << ".padding(#{json_data['margins']}.dp)"
-
end
-
end
-
-
# Individual margin attributes (with binding support)
-
487
modifiers << ".padding(top = #{margin_value(json_data['topMargin'])})" if json_data['topMargin']
-
487
modifiers << ".padding(bottom = #{margin_value(json_data['bottomMargin'])})" if json_data['bottomMargin']
-
487
modifiers << ".padding(start = #{margin_value(json_data['leftMargin'])})" if json_data['leftMargin']
-
487
modifiers << ".padding(end = #{margin_value(json_data['rightMargin'])})" if json_data['rightMargin']
-
# RTL aware margins
-
487
modifiers << ".padding(start = #{margin_value(json_data['startMargin'])})" if json_data['startMargin']
-
487
modifiers << ".padding(end = #{margin_value(json_data['endMargin'])})" if json_data['endMargin']
-
-
487
modifiers
-
end
-
-
# Convert margin value to Kotlin/Compose format with binding support
-
1
def self.margin_value(value)
-
7
if is_binding?(value)
-
# Data binding: @{propertyName} -> data.propertyName.dp
-
property = extract_binding_property(value)
-
"data.#{property}.dp"
-
else
-
7
"#{value}.dp"
-
end
-
end
-
-
1
def self.build_weight(json_data, parent_orientation = nil)
-
7
modifiers = []
-
-
# Weight only works in Row/Column contexts
-
# Weight must be greater than 0 in Compose
-
7
if json_data['weight'] && parent_orientation && json_data['weight'].to_f > 0
-
5
modifiers << ".weight(#{json_data['weight']}f)"
-
end
-
-
7
modifiers
-
end
-
-
1
def self.build_size(json_data)
-
361
modifiers = []
-
-
# Handle 'frame' attribute - object with width/height
-
# frame: { width: 100, height: 50 }
-
361
if json_data['frame'].is_a?(Hash)
-
frame = json_data['frame']
-
if frame['width']
-
if frame['width'] == 'matchParent'
-
modifiers << ".fillMaxWidth()"
-
elsif frame['width'] == 'wrapContent'
-
modifiers << ".wrapContentWidth()"
-
else
-
modifiers << ".width(#{process_dimension(frame['width'])})"
-
end
-
end
-
if frame['height']
-
if frame['height'] == 'matchParent'
-
modifiers << ".fillMaxHeight()"
-
elsif frame['height'] == 'wrapContent'
-
modifiers << ".wrapContentHeight()"
-
else
-
modifiers << ".height(#{process_dimension(frame['height'])})"
-
end
-
end
-
# If frame is specified, skip individual width/height processing
-
return modifiers
-
end
-
-
# Width - skip if weight is present and width is 0
-
361
if json_data['width'] == 'matchParent'
-
3
modifiers << ".fillMaxWidth()"
-
358
elsif json_data['width'] == 'wrapContent'
-
1
modifiers << ".wrapContentWidth()"
-
357
elsif json_data['width'] && !(json_data['weight'] && json_data['width'] == 0)
-
11
modifiers << ".width(#{process_dimension(json_data['width'])})"
-
end
-
-
# Height - skip if heightWeight is present and height is 0
-
361
if json_data['height'] == 'matchParent'
-
1
modifiers << ".fillMaxHeight()"
-
360
elsif json_data['height'] == 'wrapContent'
-
modifiers << ".wrapContentHeight()"
-
360
elsif json_data['height'] && !(json_data['heightWeight'] && json_data['height'] == 0)
-
9
modifiers << ".height(#{process_dimension(json_data['height'])})"
-
end
-
-
# Min/Max constraints
-
361
if json_data['minWidth']
-
1
modifiers << ".widthIn(min = #{json_data['minWidth']}.dp)"
-
end
-
-
361
if json_data['maxWidth']
-
1
modifiers << ".widthIn(max = #{json_data['maxWidth']}.dp)"
-
end
-
-
361
if json_data['minHeight']
-
modifiers << ".heightIn(min = #{json_data['minHeight']}.dp)"
-
end
-
-
361
if json_data['maxHeight']
-
modifiers << ".heightIn(max = #{json_data['maxHeight']}.dp)"
-
end
-
-
# Combined min/max if both specified
-
361
if json_data['minWidth'] && json_data['maxWidth']
-
3
modifiers = modifiers.reject { |m| m.include?('.widthIn') }
-
1
modifiers << ".widthIn(min = #{json_data['minWidth']}.dp, max = #{json_data['maxWidth']}.dp)"
-
end
-
-
361
if json_data['minHeight'] && json_data['maxHeight']
-
modifiers = modifiers.reject { |m| m.include?('.heightIn') }
-
modifiers << ".heightIn(min = #{json_data['minHeight']}.dp, max = #{json_data['maxHeight']}.dp)"
-
end
-
-
# Aspect ratio
-
361
if json_data['aspectWidth'] && json_data['aspectHeight']
-
1
ratio = json_data['aspectWidth'].to_f / json_data['aspectHeight'].to_f
-
1
modifiers << ".aspectRatio(#{ratio}f)"
-
end
-
-
361
modifiers
-
end
-
-
1
def self.build_shadow(json_data, required_imports = nil)
-
47
modifiers = []
-
-
47
if json_data['shadow']
-
3
required_imports&.add(:shadow)
-
-
3
if json_data['shadow'].is_a?(String)
-
# Simple shadow with color
-
2
modifiers << ".shadow(4.dp, shape = RectangleShape)"
-
1
elsif json_data['shadow'].is_a?(Hash)
-
# Complex shadow configuration
-
1
shadow = json_data['shadow']
-
1
elevation = shadow['radius'] || 4
-
1
shape = json_data['cornerRadius'] ? "RoundedCornerShape(#{json_data['cornerRadius']}.dp)" : "RectangleShape"
-
1
modifiers << ".shadow(#{elevation}.dp, shape = #{shape})"
-
end
-
end
-
-
47
modifiers
-
end
-
-
1
def self.build_background(json_data, required_imports = nil)
-
162
modifiers = []
-
-
162
if json_data['background']
-
3
required_imports&.add(:background)
-
-
# Use ResourceResolver to process background color
-
3
background_color = ResourceResolver.process_color(json_data['background'], required_imports)
-
-
3
if json_data['cornerRadius'] || json_data['borderColor'] || json_data['borderWidth']
-
1
required_imports&.add(:border)
-
1
required_imports&.add(:shape)
-
-
1
if json_data['cornerRadius']
-
1
modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
-
end
-
-
1
if json_data['borderColor'] && json_data['borderWidth']
-
modifiers << build_border_modifier(json_data, required_imports)
-
end
-
-
1
modifiers << ".background(#{background_color})"
-
else
-
2
modifiers << ".background(#{background_color})"
-
end
-
159
elsif json_data['cornerRadius'] || json_data['borderColor'] || json_data['borderWidth']
-
1
required_imports&.add(:border)
-
1
required_imports&.add(:shape)
-
-
1
if json_data['cornerRadius']
-
1
modifiers << ".clip(RoundedCornerShape(#{json_data['cornerRadius']}.dp))"
-
end
-
-
1
if json_data['borderColor'] && json_data['borderWidth']
-
1
modifiers << build_border_modifier(json_data, required_imports)
-
end
-
end
-
-
162
modifiers
-
end
-
-
1
def self.build_visibility(json_data, required_imports = nil)
-
117
modifiers = []
-
117
visibility_info = {}
-
-
# Handle visibility attribute (static or data-bound)
-
117
if json_data['visibility']
-
5
if json_data['visibility'].is_a?(String) && json_data['visibility'].start_with?('@{')
-
# Data binding for visibility
-
2
variable = json_data['visibility'].gsub('@{', '').gsub('}', '')
-
2
visibility_info[:visibility_binding] = "data.#{variable}"
-
2
required_imports&.add(:visibility_wrapper)
-
else
-
# Static visibility
-
3
visibility_info[:visibility] = json_data['visibility']
-
3
required_imports&.add(:visibility_wrapper)
-
end
-
end
-
-
# Handle hidden attribute (boolean or data binding)
-
117
if json_data['hidden']
-
4
if json_data['hidden'].is_a?(String) && json_data['hidden'].start_with?('@{')
-
# Data binding for hidden
-
2
variable = json_data['hidden'].gsub('@{', '').gsub('}', '')
-
2
visibility_info[:hidden_binding] = "data.#{variable}"
-
2
required_imports&.add(:visibility_wrapper)
-
2
elsif json_data['hidden'] == true
-
2
visibility_info[:hidden] = true
-
2
required_imports&.add(:visibility_wrapper)
-
end
-
end
-
-
# Handle alpha/opacity attribute separately (not part of visibility wrapper)
-
# Support both 'alpha' and 'opacity' for compatibility
-
117
alpha_value = json_data['alpha'] || json_data['opacity']
-
117
if alpha_value
-
2
required_imports&.add(:alpha)
-
2
modifiers << ".alpha(#{alpha_value}f)"
-
end
-
-
# Return both visibility info and modifiers
-
117
{ modifiers: modifiers, visibility_info: visibility_info }
-
end
-
-
1
def self.build_alignment(json_data, required_imports = nil, parent_type = nil)
-
141
modifiers = []
-
-
# For Row, only vertical alignment is allowed
-
141
if parent_type == 'Row'
-
4
if json_data['alignTop']
-
1
modifiers << ".align(Alignment.Top)"
-
3
elsif json_data['alignBottom']
-
modifiers << ".align(Alignment.Bottom)"
-
3
elsif json_data['centerVertical']
-
1
modifiers << ".align(Alignment.CenterVertically)"
-
end
-
# For Column, only horizontal alignment is allowed
-
137
elsif parent_type == 'Column'
-
4
if json_data['alignLeft']
-
1
modifiers << ".align(Alignment.Start)"
-
3
elsif json_data['alignRight']
-
modifiers << ".align(Alignment.End)"
-
3
elsif json_data['centerHorizontal']
-
1
modifiers << ".align(Alignment.CenterHorizontally)"
-
end
-
# For Box and other containers, full alignment options
-
133
elsif parent_type == 'Box'
-
# Check if any alignment is specified
-
4
has_alignment = json_data['alignTop'] || json_data['alignBottom'] ||
-
json_data['alignLeft'] || json_data['alignRight'] ||
-
json_data['centerHorizontal'] || json_data['centerVertical'] ||
-
json_data['centerInParent']
-
-
# First check for both-direction constraints (centering behavior)
-
4
has_horizontal_both = json_data['alignLeft'] && json_data['alignRight']
-
4
has_vertical_both = json_data['alignTop'] && json_data['alignBottom']
-
-
# Handle combined alignments
-
4
if has_horizontal_both && has_vertical_both
-
# Both horizontal and vertical constraints - center completely
-
modifiers << ".align(Alignment.Center)"
-
4
elsif has_horizontal_both && json_data['alignTop']
-
# Center horizontally, align top
-
required_imports&.add(:bias_alignment)
-
modifiers << ".align(BiasAlignment(0f, -1f))"
-
4
elsif has_horizontal_both && json_data['alignBottom']
-
# Center horizontally, align bottom
-
required_imports&.add(:bias_alignment)
-
modifiers << ".align(BiasAlignment(0f, 1f))"
-
4
elsif has_horizontal_both
-
# Just center horizontally
-
required_imports&.add(:bias_alignment)
-
modifiers << ".align(BiasAlignment(0f, 0f))"
-
4
elsif has_vertical_both && json_data['alignLeft']
-
# Center vertically, align left
-
modifiers << ".align(Alignment.CenterStart)"
-
4
elsif has_vertical_both && json_data['alignRight']
-
# Center vertically, align right
-
modifiers << ".align(Alignment.CenterEnd)"
-
4
elsif has_vertical_both
-
# Just center vertically
-
required_imports&.add(:bias_alignment)
-
modifiers << ".align(BiasAlignment(0f, 0f))"
-
4
elsif json_data['alignTop'] && json_data['alignLeft']
-
1
modifiers << ".align(Alignment.TopStart)"
-
3
elsif json_data['alignTop'] && json_data['alignRight']
-
modifiers << ".align(Alignment.TopEnd)"
-
3
elsif json_data['alignBottom'] && json_data['alignLeft']
-
modifiers << ".align(Alignment.BottomStart)"
-
3
elsif json_data['alignBottom'] && json_data['alignRight']
-
1
modifiers << ".align(Alignment.BottomEnd)"
-
2
elsif json_data['alignTop'] && json_data['centerHorizontal']
-
# TopCenter doesn't exist in BoxScope, use BiasAlignment
-
1
required_imports&.add(:bias_alignment)
-
1
modifiers << ".align(BiasAlignment(0f, -1f))"
-
1
elsif json_data['alignBottom'] && json_data['centerHorizontal']
-
# BottomCenter doesn't exist in BoxScope, use BiasAlignment
-
required_imports&.add(:bias_alignment)
-
modifiers << ".align(BiasAlignment(0f, 1f))"
-
1
elsif json_data['alignLeft'] && json_data['centerVertical']
-
modifiers << ".align(Alignment.CenterStart)"
-
1
elsif json_data['alignRight'] && json_data['centerVertical']
-
modifiers << ".align(Alignment.CenterEnd)"
-
1
elsif json_data['centerInParent']
-
1
modifiers << ".align(Alignment.Center)"
-
# Handle single alignments for Box
-
elsif json_data['alignTop']
-
# Just top alignment - align to top-left
-
required_imports&.add(:bias_alignment)
-
modifiers << ".align(BiasAlignment(-1f, -1f))"
-
elsif json_data['alignBottom']
-
# Just bottom alignment - align to bottom-left
-
required_imports&.add(:bias_alignment)
-
modifiers << ".align(BiasAlignment(-1f, 1f))"
-
elsif json_data['alignLeft']
-
# Just left alignment - align to top-left
-
required_imports&.add(:bias_alignment)
-
modifiers << ".align(BiasAlignment(-1f, -1f))"
-
elsif json_data['alignRight']
-
# Just right alignment - align to top-right
-
required_imports&.add(:bias_alignment)
-
modifiers << ".align(BiasAlignment(1f, -1f))"
-
elsif json_data['centerHorizontal']
-
# Center horizontally only - align to top-center
-
required_imports&.add(:bias_alignment)
-
modifiers << ".align(BiasAlignment(0f, -1f))"
-
elsif json_data['centerVertical']
-
# Center vertically only - align to center-left
-
required_imports&.add(:bias_alignment)
-
modifiers << ".align(BiasAlignment(-1f, 0f))"
-
elsif !has_alignment
-
# No alignment specified - default to TopStart (top-left)
-
modifiers << ".align(Alignment.TopStart)"
-
end
-
end
-
-
141
modifiers
-
end
-
-
1
def self.build_relative_positioning(json_data)
-
# These attributes require ConstraintLayout
-
# They generate constraint references instead of modifiers
-
8
constraints = []
-
-
# Extract margins for use in constraints (with binding support)
-
8
top_margin = constraint_margin_value(json_data['topMargin'])
-
8
bottom_margin = constraint_margin_value(json_data['bottomMargin'])
-
8
start_margin = constraint_margin_value(json_data['leftMargin'])
-
8
end_margin = constraint_margin_value(json_data['rightMargin'])
-
-
8
if json_data['margins'] && json_data['margins'].is_a?(Array) && json_data['margins'].length == 4
-
top_margin = json_data['margins'][0].to_s + ".dp" unless json_data['topMargin']
-
end_margin = json_data['margins'][1].to_s + ".dp" unless json_data['rightMargin']
-
bottom_margin = json_data['margins'][2].to_s + ".dp" unless json_data['bottomMargin']
-
start_margin = json_data['margins'][3].to_s + ".dp" unless json_data['leftMargin']
-
end
-
-
# Relative to other views
-
8
if json_data['alignTopOfView']
-
2
margin = has_constraint_margin?(bottom_margin) ? ", margin = #{bottom_margin}" : ""
-
2
constraints << "bottom.linkTo(#{json_data['alignTopOfView']}.top#{margin})"
-
end
-
-
8
if json_data['alignBottomOfView']
-
margin = has_constraint_margin?(top_margin) ? ", margin = #{top_margin}" : ""
-
constraints << "top.linkTo(#{json_data['alignBottomOfView']}.bottom#{margin})"
-
end
-
-
8
if json_data['alignLeftOfView']
-
margin = has_constraint_margin?(end_margin) ? ", margin = #{end_margin}" : ""
-
constraints << "end.linkTo(#{json_data['alignLeftOfView']}.start#{margin})"
-
end
-
-
8
if json_data['alignRightOfView']
-
margin = has_constraint_margin?(start_margin) ? ", margin = #{start_margin}" : ""
-
constraints << "start.linkTo(#{json_data['alignRightOfView']}.end#{margin})"
-
end
-
-
# Align edges with other views
-
# For align operations, use negative margins to move in the expected direction
-
8
if json_data['alignTopView']
-
# alignTop with topMargin means move DOWN from the aligned position
-
# linkTo margin pushes away, so use negative to pull closer (move down)
-
margin = has_constraint_margin?(top_margin) ? ", margin = (-#{top_margin})" : ""
-
constraints << "top.linkTo(#{json_data['alignTopView']}.top#{margin})"
-
end
-
-
8
if json_data['alignBottomView']
-
# alignBottom with bottomMargin means move UP from the aligned position
-
# linkTo margin pushes away, so use negative to pull closer (move up)
-
margin = has_constraint_margin?(bottom_margin) ? ", margin = (-#{bottom_margin})" : ""
-
constraints << "bottom.linkTo(#{json_data['alignBottomView']}.bottom#{margin})"
-
end
-
-
8
if json_data['alignLeftView']
-
# alignLeft with leftMargin means move RIGHT from the aligned position
-
# linkTo margin pushes away, so use negative to pull closer (move right)
-
margin = has_constraint_margin?(start_margin) ? ", margin = (-#{start_margin})" : ""
-
constraints << "start.linkTo(#{json_data['alignLeftView']}.start#{margin})"
-
end
-
-
8
if json_data['alignRightView']
-
# alignRight with rightMargin means move LEFT from the aligned position
-
# linkTo margin pushes away, so use negative to pull closer (move left)
-
margin = has_constraint_margin?(end_margin) ? ", margin = (-#{end_margin})" : ""
-
constraints << "end.linkTo(#{json_data['alignRightView']}.end#{margin})"
-
end
-
-
# Center with other views
-
8
if json_data['alignCenterVerticalView']
-
constraints << "top.linkTo(#{json_data['alignCenterVerticalView']}.top)"
-
constraints << "bottom.linkTo(#{json_data['alignCenterVerticalView']}.bottom)"
-
end
-
-
8
if json_data['alignCenterHorizontalView']
-
constraints << "start.linkTo(#{json_data['alignCenterHorizontalView']}.start)"
-
constraints << "end.linkTo(#{json_data['alignCenterHorizontalView']}.end)"
-
end
-
-
# Parent constraints
-
# For parent alignment, margins should work normally as offsets
-
8
if json_data['alignTop']
-
4
margin = has_constraint_margin?(top_margin) ? ", margin = #{top_margin}" : ""
-
4
constraints << "top.linkTo(parent.top#{margin})"
-
end
-
-
8
if json_data['alignBottom']
-
margin = has_constraint_margin?(bottom_margin) ? ", margin = #{bottom_margin}" : ""
-
constraints << "bottom.linkTo(parent.bottom#{margin})"
-
end
-
-
8
if json_data['alignLeft']
-
2
margin = has_constraint_margin?(start_margin) ? ", margin = #{start_margin}" : ""
-
2
constraints << "start.linkTo(parent.start#{margin})"
-
end
-
-
8
if json_data['alignRight']
-
margin = has_constraint_margin?(end_margin) ? ", margin = #{end_margin}" : ""
-
constraints << "end.linkTo(parent.end#{margin})"
-
end
-
-
8
if json_data['centerHorizontal']
-
constraints << "start.linkTo(parent.start)"
-
constraints << "end.linkTo(parent.end)"
-
end
-
-
8
if json_data['centerVertical']
-
constraints << "top.linkTo(parent.top)"
-
constraints << "bottom.linkTo(parent.bottom)"
-
end
-
-
8
if json_data['centerInParent']
-
2
constraints << "top.linkTo(parent.top)"
-
2
constraints << "bottom.linkTo(parent.bottom)"
-
2
constraints << "start.linkTo(parent.start)"
-
2
constraints << "end.linkTo(parent.end)"
-
end
-
-
8
constraints
-
end
-
-
1
def self.format(modifiers, depth)
-
187
return "" if modifiers.empty?
-
-
# Check if first modifier is already "Modifier"
-
102
if modifiers[0] == "Modifier"
-
3
code = "\n" + indent("modifier = Modifier", depth + 1)
-
# Skip the first "Modifier" and process the rest
-
3
modifiers[1..-1].each do |mod|
-
9
code += "\n" + indent(" #{mod}", depth + 1)
-
end
-
else
-
99
code = "\n" + indent("modifier = Modifier", depth + 1)
-
-
99
if modifiers.length == 1 && modifiers[0].start_with?('.')
-
72
code += modifiers[0]
-
else
-
27
modifiers.each do |mod|
-
57
code += "\n" + indent(" #{mod}", depth + 1)
-
end
-
end
-
end
-
-
102
code
-
end
-
-
# Build lifecycle event effects (onAppear/onDisappear)
-
# Returns a hash with :before (code before content) and :after (code after content)
-
1
def self.build_lifecycle_effects(json_data, depth, required_imports = nil)
-
14
result = { before: "", after: "" }
-
-
14
if json_data['onAppear']
-
8
required_imports&.add(:launched_effect)
-
8
handler = json_data['onAppear']
-
-
8
result[:before] += indent("// onAppear lifecycle event", depth)
-
8
result[:before] += "\n" + indent("LaunchedEffect(Unit) {", depth)
-
8
if handler.include?(':')
-
1
method_name = handler.gsub(':', '')
-
1
result[:before] += "\n" + indent("data.#{method_name}?.invoke()", depth + 1)
-
else
-
7
result[:before] += "\n" + indent("data.#{handler}?.invoke()", depth + 1)
-
end
-
8
result[:before] += "\n" + indent("}", depth)
-
8
result[:before] += "\n"
-
end
-
-
14
if json_data['onDisappear']
-
6
required_imports&.add(:disposable_effect)
-
6
handler = json_data['onDisappear']
-
-
6
result[:before] += indent("// onDisappear lifecycle event", depth)
-
6
result[:before] += "\n" + indent("DisposableEffect(Unit) {", depth)
-
6
result[:before] += "\n" + indent("onDispose {", depth + 1)
-
6
if handler.include?(':')
-
1
method_name = handler.gsub(':', '')
-
1
result[:before] += "\n" + indent("data.#{method_name}?.invoke()", depth + 2)
-
else
-
5
result[:before] += "\n" + indent("data.#{handler}?.invoke()", depth + 2)
-
end
-
6
result[:before] += "\n" + indent("}", depth + 1)
-
6
result[:before] += "\n" + indent("}", depth)
-
6
result[:before] += "\n"
-
end
-
-
14
result
-
end
-
-
# Check if component has lifecycle events
-
1
def self.has_lifecycle_events?(json_data)
-
9
json_data['onAppear'] || json_data['onDisappear']
-
end
-
-
# Convert event handler to method call
-
# onClick -> binding format only: @{functionName} -> data.functionName?.invoke()
-
1
def self.get_event_handler_call(handler, is_camel_case: false)
-
# Extract function name from binding format @{functionName}
-
4
if handler.match?(/^@\{(.+)\}$/)
-
method_name = handler.match(/^@\{(.+)\}$/)[1]
-
"data.#{method_name}?.invoke()"
-
else
-
# Direct function name (non-binding)
-
4
"data.#{handler}?.invoke()"
-
end
-
end
-
-
# Check if handler is binding format (@{functionName})
-
1
def self.is_binding?(value)
-
25
value.is_a?(String) && value.match?(/^@\{.+\}$/)
-
end
-
-
# Extract property name from binding expression
-
# "@{propertyName}" -> "propertyName"
-
1
def self.extract_binding_property(value)
-
7
return nil unless value.is_a?(String)
-
7
if value.match(/^@\{(.+)\}$/)
-
7
$1
-
else
-
value
-
end
-
end
-
-
# Convert margin value to Kotlin/Compose format for constraint linkTo() with binding support
-
# Returns nil for no margin, or the formatted value (e.g., "8.dp" or "data.margin.dp")
-
1
def self.constraint_margin_value(value)
-
32
return nil if value.nil?
-
-
1
if is_binding?(value)
-
# Data binding: @{propertyName} -> data.propertyName.dp
-
property = extract_binding_property(value)
-
"data.#{property}.dp"
-
1
elsif value.is_a?(Numeric) && value > 0
-
1
"#{value}.dp"
-
elsif value.is_a?(String)
-
# Try to parse as number
-
num = value.to_i
-
num > 0 ? "#{num}.dp" : nil
-
else
-
nil
-
end
-
end
-
-
# Check if constraint margin value is present
-
1
def self.has_constraint_margin?(margin_value)
-
8
return false if margin_value.nil?
-
1
return true if margin_value.is_a?(String) && margin_value.length > 0
-
false
-
end
-
-
1
private
-
-
# Build border modifier with support for solid/dashed/dotted styles
-
1
def self.build_border_modifier(json_data, required_imports = nil)
-
1
border_color = ResourceResolver.process_color(json_data['borderColor'], required_imports)
-
1
border_width = json_data['borderWidth']
-
1
border_style = json_data['borderStyle'] || 'solid'
-
1
border_shape = json_data['cornerRadius'] ? "RoundedCornerShape(#{json_data['cornerRadius']}.dp)" : "RectangleShape"
-
-
1
case border_style
-
when 'dashed'
-
required_imports&.add(:dashed_border)
-
".dashedBorder(#{border_width}.dp, #{border_color}, #{border_shape})"
-
when 'dotted'
-
required_imports&.add(:dashed_border)
-
".dottedBorder(#{border_width}.dp, #{border_color}, #{border_shape})"
-
else # 'solid' or default
-
1
".border(#{border_width}.dp, #{border_color}, #{border_shape})"
-
end
-
end
-
-
# Process dimension value - handles data bindings and numeric values
-
1
def self.process_dimension(value)
-
26
return "#{value}.dp" if value.is_a?(Numeric)
-
-
5
if value.is_a?(String)
-
# Check for data binding syntax @{variableName}
-
4
if value.match(/@\{([^}]+)\}/)
-
2
variable = $1
-
# Data binding returns Int/Float from ViewModel, append .dp
-
2
return "data.#{variable}.dp"
-
end
-
# Regular string value (might be percentage or other)
-
2
return "#{value}.dp"
-
end
-
-
1
"0.dp"
-
end
-
-
1
def self.indent(text, level)
-
236
return text if level == 0
-
233
spaces = ' ' * level
-
233
text.split("\n").map { |line|
-
233
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'rexml/document'
-
1
require 'json'
-
1
require_relative '../../core/config_manager'
-
1
require_relative '../../core/project_finder'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Helpers
-
1
class ResourceResolver
-
1
class << self
-
# Don't cache - just load each time to avoid issues
-
1
def cached_config
-
477
Core::ConfigManager.load_config
-
end
-
-
1
def cached_source_path
-
240
Core::ProjectFinder.get_full_source_path || Dir.pwd
-
end
-
-
# Process text with data binding and resource resolution
-
1
def process_text(text, required_imports = nil)
-
114
return quote(text) unless text.is_a?(String)
-
-
# Handle data binding expressions
-
114
if text.match(/@\{([^}]+)\}/)
-
3
variable = $1
-
3
if variable.include?(' ?? ')
-
1
parts = variable.split(' ?? ')
-
1
var_name = parts[0].strip
-
1
return "\"\${data.#{var_name}}\""
-
else
-
2
return "\"\${data.#{variable}}\""
-
end
-
end
-
-
# Skip resource resolution if we're in the extraction phase
-
# (Resources directory doesn't exist yet)
-
111
source_directory = cached_config['source_directory'] || 'src/main'
-
111
layouts_dir = File.join(cached_source_path, source_directory, cached_config['layouts_directory'] || 'assets/Layouts')
-
111
resources_dir = File.join(layouts_dir, 'Resources')
-
-
# If Resources directory doesn't exist, we're in extraction phase
-
# Just return quoted text
-
111
return quote(text) unless File.exist?(resources_dir)
-
-
# Try to resolve as a string resource
-
1
resolved = resolve_string(text, cached_config, cached_source_path)
-
1
if resolved.include?('stringResource')
-
1
required_imports&.add(:string_resource)
-
1
required_imports&.add(:r_class)
-
end
-
1
resolved
-
end
-
-
# Process color with resource resolution
-
1
def process_color(color, required_imports = nil)
-
125
return nil unless color.is_a?(String)
-
-
# Handle data binding expressions
-
124
if color.start_with?('@{') || color.start_with?('${}')
-
1
return "Color(android.graphics.Color.parseColor(#{quote(color)}))"
-
end
-
-
# Skip resource resolution if we're in the extraction phase
-
# (Resources directory doesn't exist yet)
-
123
source_directory = cached_config['source_directory'] || 'src/main'
-
123
layouts_dir = File.join(cached_source_path, source_directory, cached_config['layouts_directory'] || 'assets/Layouts')
-
123
resources_dir = File.join(layouts_dir, 'Resources')
-
-
# If Resources directory doesn't exist, we're in extraction phase
-
# Just return standard color parsing
-
123
unless File.exist?(resources_dir)
-
121
return "Color(android.graphics.Color.parseColor(#{quote(color)}))"
-
end
-
-
2
resolved = resolve_color(color, cached_config, cached_source_path)
-
2
if resolved&.include?('colorResource')
-
2
required_imports&.add(:color_resource)
-
2
required_imports&.add(:r_class)
-
end
-
2
resolved
-
end
-
-
1
private
-
-
# Check if a string resource exists in strings.xml
-
1
def resolve_string(text, config, source_path)
-
1
return quote(text) unless text.is_a?(String)
-
-
# Skip if it's a data binding expression
-
1
return quote(text) if text.start_with?('@{') || text.start_with?('${')
-
-
# Try to find the string in strings.xml
-
1
string_key = find_string_key(text, config, source_path)
-
-
1
if string_key
-
# Return stringResource reference
-
1
"stringResource(R.string.#{string_key})"
-
else
-
# Return quoted string
-
quote(text)
-
end
-
end
-
-
# Check if a color resource exists
-
1
def resolve_color(color, config, source_path)
-
2
return nil unless color.is_a?(String)
-
-
# Skip if it's a data binding expression
-
2
return "Color(android.graphics.Color.parseColor(#{quote(color)}))" if color.start_with?('@{') || color.start_with?('${')
-
-
# Try to find the color in colors.json
-
2
color_key = find_color_key(color, config, source_path)
-
-
2
if color_key
-
# Return colorResource reference
-
2
"colorResource(R.color.#{color_key})"
-
else
-
# Return Color.parseColor
-
"Color(android.graphics.Color.parseColor(#{quote(color)}))"
-
end
-
end
-
-
1
private
-
-
1
def cached_strings_data
-
1
source_directory = cached_config['source_directory'] || 'src/main'
-
1
layouts_dir = File.join(cached_source_path, source_directory, cached_config['layouts_directory'] || 'assets/Layouts')
-
1
strings_file = File.join(layouts_dir, 'Resources', 'strings.json')
-
-
1
return {} unless File.exist?(strings_file)
-
-
begin
-
1
JSON.parse(File.read(strings_file))
-
rescue JSON::ParserError
-
{}
-
end
-
end
-
-
1
def cached_colors_data
-
2
source_directory = cached_config['source_directory'] || 'src/main'
-
2
layouts_dir = File.join(cached_source_path, source_directory, cached_config['layouts_directory'] || 'assets/Layouts')
-
2
colors_file = File.join(layouts_dir, 'Resources', 'colors.json')
-
-
2
return {} unless File.exist?(colors_file)
-
-
begin
-
2
JSON.parse(File.read(colors_file))
-
rescue JSON::ParserError
-
{}
-
end
-
end
-
-
1
def find_string_key(text, config, source_path)
-
1
strings_data = cached_strings_data
-
-
# First, check if the text itself is a resource key (snake_case like "login_password")
-
# This handles the case where JSON has text: "login_password" which should resolve to R.string.login_password
-
1
if text.match?(/^[a-z]+(_[a-z0-9]+)+$/)
-
strings_data.each do |file_prefix, file_strings|
-
next unless file_strings.is_a?(Hash)
-
-
file_strings.each do |key, _value|
-
full_key = "#{file_prefix}_#{key}"
-
if full_key == text
-
# Text matches an existing resource key directly
-
return text
-
end
-
end
-
end
-
end
-
-
# Search through all file prefixes for matching values
-
1
strings_data.each do |file_prefix, file_strings|
-
1
next unless file_strings.is_a?(Hash)
-
-
1
file_strings.each do |key, value|
-
1
if value == text
-
# Return the full key with prefix
-
1
return "#{file_prefix}_#{key}"
-
end
-
end
-
end
-
-
nil
-
end
-
-
1
def find_color_key(color, config, source_path)
-
2
colors_data = cached_colors_data
-
-
# First check if the color itself is a key in colors.json
-
2
if colors_data.has_key?(color)
-
1
return color
-
end
-
-
# If it's a hex color, normalize and search by value
-
1
if color.match?(/^#?[A-Fa-f0-9]{6,8}$/)
-
1
normalized_color = normalize_color(color)
-
-
# Search through colors by value
-
1
colors_data.each do |key, value|
-
1
if normalize_color(value) == normalized_color
-
1
return key
-
end
-
end
-
end
-
-
# Also check colors.xml for predefined Android colors
-
# These are colors that might be defined in colors.xml but not in colors.json
-
colors_xml_path = File.join(source_path, config['source_directory'] || 'src/main', 'res/values/colors.xml')
-
if File.exist?(colors_xml_path)
-
# Quick check - if the color name exists in colors.xml
-
# we'll assume it's available (proper check would parse XML)
-
xml_content = File.read(colors_xml_path)
-
if xml_content.include?("name='#{color}'") || xml_content.include?("name=\"#{color}\"")
-
return color
-
end
-
end
-
-
nil
-
end
-
-
1
def normalize_color(color)
-
2
return nil unless color.is_a?(String)
-
-
# Remove # if present and convert to lowercase
-
2
color.sub(/^#/, '').downcase
-
end
-
-
1
def quote(text)
-
# Escape special characters properly
-
232
escaped = text.to_s.gsub('\\', '\\\\\\\\')
-
.gsub('"', '\\"')
-
.gsub("\n", '\\n')
-
.gsub("\r", '\\r')
-
.gsub("\t", '\\t')
-
232
"\"#{escaped}\""
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Helpers
-
1
class VisibilityHelper
-
1
def self.wrap_with_visibility(json_data, component_code, depth, required_imports)
-
67
visibility_result = ModifierBuilder.build_visibility(json_data, required_imports)
-
67
visibility_info = visibility_result[:visibility_info]
-
-
# If no visibility attributes, return the component as-is
-
67
return component_code if visibility_info.empty?
-
-
# Build VisibilityWrapper
-
5
wrapper_code = indent("VisibilityWrapper(", depth)
-
-
# Add visibility parameters
-
5
if visibility_info[:visibility_binding]
-
1
wrapper_code += "\n" + indent("visibility = #{visibility_info[:visibility_binding]},", depth + 1)
-
4
elsif visibility_info[:visibility]
-
2
wrapper_code += "\n" + indent("visibility = \"#{visibility_info[:visibility]}\",", depth + 1)
-
end
-
-
5
if visibility_info[:hidden_binding]
-
1
wrapper_code += "\n" + indent("hidden = #{visibility_info[:hidden_binding]},", depth + 1)
-
4
elsif visibility_info[:hidden]
-
1
wrapper_code += "\n" + indent("hidden = true,", depth + 1)
-
end
-
-
5
wrapper_code += "\n" + indent(") {", depth)
-
5
wrapper_code += "\n" + component_code
-
5
wrapper_code += "\n" + indent("}", depth)
-
-
5
wrapper_code
-
end
-
-
1
def self.should_skip_render?(json_data)
-
# Check if component should not be rendered at all (static gone/hidden)
-
67
return true if json_data['visibility'] == 'gone' && !json_data['visibility'].to_s.include?('@{')
-
66
return true if json_data['hidden'] == true && !json_data['hidden'].to_s.include?('@{')
-
65
false
-
end
-
-
1
private
-
-
1
def self.indent(text, level)
-
20
return text if level == 0
-
5
spaces = ' ' * level
-
5
text.split("\n").map { |line|
-
5
line.empty? ? line : spaces + line
-
}.join("\n")
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'fileutils'
-
1
require 'json'
-
1
require_relative '../../core/config_manager'
-
1
require_relative '../../core/project_finder'
-
-
1
module KjuiTools
-
1
module Compose
-
1
module Setup
-
1
class ComposeSetup
-
1
def initialize(project_file_path = nil)
-
12
@project_file_path = project_file_path
-
12
@config = Core::ConfigManager.load_config
-
12
@source_path = Core::ProjectFinder.get_full_source_path
-
12
@package_name = Core::ProjectFinder.package_name
-
end
-
-
1
def run_full_setup
-
2
puts "Setting up Compose project..."
-
-
# Create directory structure
-
2
create_directory_structure
-
-
# Copy base files
-
2
copy_base_files
-
-
# Create hotloader config
-
2
create_hotloader_config
-
-
# Setup network security for hot reload
-
2
setup_network_security
-
-
# Update build.gradle
-
2
update_build_gradle
-
-
# Create sample layouts
-
2
create_sample_layouts
-
-
2
puts "Compose setup complete!"
-
end
-
-
1
private
-
-
1
def create_directory_structure
-
1
puts "Creating directory structure..."
-
-
# Get source directory from config
-
1
source_dir = @config['source_directory'] || 'src/main'
-
-
directories = [
-
1
File.join(source_dir, 'assets/Layouts'),
-
File.join(source_dir, 'assets/Styles'),
-
package_path('ui/components'),
-
package_path('ui/theme')
-
# data, viewmodels, views directories will be created by g view command
-
]
-
-
1
directories.each do |dir|
-
# All paths should be relative to the project root
-
4
FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
-
4
puts " Created: #{dir}"
-
end
-
end
-
-
1
def copy_base_files
-
puts "Creating base files..."
-
-
# Create theme file
-
create_theme_file
-
-
# Create base components
-
create_base_components
-
-
# Create MainActivity with Compose setup
-
create_main_activity
-
end
-
-
1
def create_theme_file
-
theme_path = File.join(package_path('ui/theme'), 'Theme.kt')
-
-
content = <<~KOTLIN
-
package #{@package_name}.ui.theme
-
-
import androidx.compose.foundation.isSystemInDarkTheme
-
import androidx.compose.material3.*
-
import androidx.compose.runtime.Composable
-
import androidx.compose.ui.graphics.Color
-
-
private val LightColorScheme = lightColorScheme(
-
primary = Color(0xFF6200EE),
-
onPrimary = Color.White,
-
secondary = Color(0xFF03DAC6),
-
onSecondary = Color.Black,
-
background = Color(0xFFF5F5F5),
-
onBackground = Color.Black,
-
surface = Color.White,
-
onSurface = Color.Black,
-
)
-
-
private val DarkColorScheme = darkColorScheme(
-
primary = Color(0xFFBB86FC),
-
onPrimary = Color.Black,
-
secondary = Color(0xFF03DAC6),
-
onSecondary = Color.Black,
-
background = Color(0xFF121212),
-
onBackground = Color.White,
-
surface = Color(0xFF121212),
-
onSurface = Color.White,
-
)
-
-
@Composable
-
fun KotlinJsonUITheme(
-
darkTheme: Boolean = isSystemInDarkTheme(),
-
content: @Composable () -> Unit
-
) {
-
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
-
-
MaterialTheme(
-
colorScheme = colorScheme,
-
typography = Typography(),
-
content = content
-
)
-
}
-
KOTLIN
-
-
File.write(theme_path, content)
-
puts " Created: Theme.kt"
-
end
-
-
1
def create_base_components
-
# Create JsonUILoader component
-
loader_path = File.join(package_path('ui/components'), 'JsonUILoader.kt')
-
-
content = <<~KOTLIN
-
package #{@package_name}.ui.components
-
-
import androidx.compose.runtime.*
-
import androidx.compose.ui.platform.LocalContext
-
import kotlinx.coroutines.Dispatchers
-
import kotlinx.coroutines.withContext
-
import org.json.JSONObject
-
-
/**
-
* Loads and renders a JSON UI layout
-
*/
-
@Composable
-
fun JsonUILoader(
-
layoutName: String,
-
onAction: (String) -> Unit = {}
-
) {
-
val context = LocalContext.current
-
var jsonContent by remember { mutableStateOf<JSONObject?>(null) }
-
-
LaunchedEffect(layoutName) {
-
withContext(Dispatchers.IO) {
-
try {
-
val inputStream = context.assets.open("Layouts/$layoutName.json")
-
val json = inputStream.bufferedReader().use { it.readText() }
-
jsonContent = JSONObject(json)
-
} catch (e: Exception) {
-
e.printStackTrace()
-
}
-
}
-
}
-
-
jsonContent?.let { json ->
-
// Render the JSON UI
-
JsonUIRenderer(json = json, onAction = onAction)
-
}
-
}
-
-
@Composable
-
fun JsonUIRenderer(
-
json: JSONObject,
-
onAction: (String) -> Unit = {}
-
) {
-
// TODO: Implement JSON to Compose rendering
-
// This will be generated by kjui_tools
-
}
-
KOTLIN
-
-
File.write(loader_path, content)
-
puts " Created: JsonUILoader.kt"
-
end
-
-
1
def create_main_activity
-
source_dir = @config['source_directory'] || 'src/main'
-
package_dirs = @package_name.gsub('.', '/')
-
activity_path = File.join(source_dir, "kotlin/#{package_dirs}", 'MainActivity.kt')
-
-
content = <<~KOTLIN
-
package #{@package_name}
-
-
import android.os.Bundle
-
import androidx.activity.ComponentActivity
-
import androidx.activity.compose.setContent
-
import androidx.compose.foundation.layout.fillMaxSize
-
import androidx.compose.material3.*
-
import androidx.compose.runtime.*
-
import androidx.compose.ui.Modifier
-
import #{@package_name}.ui.theme.KotlinJsonUITheme
-
import #{@package_name}.ui.components.JsonUILoader
-
-
class MainActivity : ComponentActivity() {
-
override fun onCreate(savedInstanceState: Bundle?) {
-
super.onCreate(savedInstanceState)
-
setContent {
-
KotlinJsonUITheme {
-
Surface(
-
modifier = Modifier.fillMaxSize(),
-
color = MaterialTheme.colorScheme.background
-
) {
-
// Load main layout from JSON
-
JsonUILoader(
-
layoutName = "main",
-
onAction = { action ->
-
handleAction(action)
-
}
-
)
-
}
-
}
-
}
-
}
-
-
private fun handleAction(action: String) {
-
// Handle actions from JSON UI
-
when (action) {
-
// Add action handlers here
-
else -> println("Unknown action: $action")
-
}
-
}
-
}
-
KOTLIN
-
-
File.write(activity_path, content) unless File.exist?(activity_path)
-
puts " Created: MainActivity.kt" unless File.exist?(activity_path)
-
end
-
-
1
def update_build_gradle
-
1
puts "Updating build.gradle..."
-
-
1
gradle_file = find_app_gradle_file
-
1
return unless gradle_file
-
-
content = File.read(gradle_file)
-
-
# Check if Compose is already configured
-
unless content.include?('compose')
-
puts " Adding Compose dependencies to build.gradle..."
-
-
# Add compose to buildFeatures
-
unless content.include?('buildFeatures')
-
content.gsub!(/android\s*\{/, "android {\n buildFeatures {\n compose = true\n }")
-
end
-
-
# Add compose options
-
unless content.include?('composeOptions')
-
content.gsub!(/android\s*\{/, "android {\n composeOptions {\n kotlinCompilerExtensionVersion = \"1.5.7\"\n }")
-
end
-
-
# Add Compose BOM
-
unless content.include?('androidx.compose:compose-bom')
-
dependencies_section = content.match(/dependencies\s*\{(.*?)\}/m)
-
if dependencies_section
-
new_deps = <<~GRADLE
-
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
-
implementation("androidx.compose.ui:ui")
-
implementation("androidx.compose.ui:ui-tooling-preview")
-
implementation("androidx.compose.material3:material3")
-
implementation("androidx.compose.runtime:runtime")
-
implementation("androidx.activity:activity-compose:1.8.0")
-
GRADLE
-
-
content.gsub!(/dependencies\s*\{/, "dependencies {\n#{new_deps}")
-
end
-
end
-
-
File.write(gradle_file, content)
-
puts " Updated build.gradle with Compose dependencies"
-
else
-
puts " Compose already configured in build.gradle"
-
end
-
end
-
-
1
def create_hotloader_config
-
puts "Creating hotloader configuration..."
-
-
# Determine the correct project directory
-
project_root = Core::ProjectFinder.project_dir || Dir.pwd
-
-
# Check if we're in sample-app
-
if File.exist?(File.join(project_root, 'sample-app'))
-
assets_dir = File.join(project_root, 'sample-app', 'src', 'main', 'assets')
-
else
-
source_dir = @config['source_directory'] || 'src/main'
-
assets_dir = File.join(project_root, source_dir, 'assets')
-
end
-
-
FileUtils.mkdir_p(assets_dir)
-
-
# Get IP from config or detect it
-
ip = if @config['hotloader'] && @config['hotloader']['ip']
-
@config['hotloader']['ip']
-
else
-
get_local_ip || '10.0.2.2' # Default to Android emulator IP
-
end
-
-
port = if @config['hotloader'] && @config['hotloader']['port']
-
@config['hotloader']['port']
-
else
-
8081
-
end
-
-
# Create hotloader.json
-
hotloader_config_path = File.join(assets_dir, 'hotloader.json')
-
hotloader_config = {
-
'ip' => ip,
-
'port' => port,
-
'enabled' => false, # Default to disabled for initial setup
-
'websocket_endpoint' => "ws://#{ip}:#{port}",
-
'http_endpoint' => "http://#{ip}:#{port}"
-
}
-
-
File.write(hotloader_config_path, JSON.pretty_generate(hotloader_config))
-
puts " Created: hotloader.json (IP: #{ip}:#{port})"
-
end
-
-
1
def setup_network_security
-
puts "Setting up network security for hot reload..."
-
-
# Determine the correct project directory
-
project_root = Core::ProjectFinder.project_dir || Dir.pwd
-
-
# Check if we're in sample-app
-
if File.exist?(File.join(project_root, 'sample-app'))
-
res_dir = File.join(project_root, 'sample-app', 'src', 'main', 'res', 'xml')
-
debug_dir = File.join(project_root, 'sample-app', 'src', 'debug')
-
manifest_path = File.join(project_root, 'sample-app', 'src', 'main', 'AndroidManifest.xml')
-
else
-
source_dir = @config['source_directory'] || 'src/main'
-
res_dir = File.join(project_root, source_dir, 'res', 'xml')
-
debug_dir = File.join(project_root, 'src', 'debug')
-
manifest_path = File.join(project_root, source_dir, 'AndroidManifest.xml')
-
end
-
-
# Create network security config
-
FileUtils.mkdir_p(res_dir)
-
network_config_path = File.join(res_dir, 'network_security_config.xml')
-
-
network_config = <<~XML
-
<?xml version="1.0" encoding="utf-8"?>
-
<network-security-config>
-
<!-- Allow cleartext traffic for hot reload development server -->
-
<domain-config cleartextTrafficPermitted="true">
-
<!-- Android emulator localhost -->
-
<domain includeSubdomains="true">10.0.2.2</domain>
-
<!-- Common local network ranges -->
-
<domain includeSubdomains="true">localhost</domain>
-
<domain includeSubdomains="true">127.0.0.1</domain>
-
<!-- Local network IPs (adjust as needed) -->
-
<domain includeSubdomains="true">192.168.0.0/16</domain>
-
<domain includeSubdomains="true">192.168.1.0/24</domain>
-
<domain includeSubdomains="true">192.168.3.0/24</domain>
-
<domain includeSubdomains="true">10.0.0.0/8</domain>
-
</domain-config>
-
-
<!-- Default configuration for production -->
-
<base-config cleartextTrafficPermitted="false">
-
<trust-anchors>
-
<certificates src="system" />
-
</trust-anchors>
-
</base-config>
-
</network-security-config>
-
XML
-
-
File.write(network_config_path, network_config)
-
puts " Created: network_security_config.xml"
-
-
# Create debug-specific AndroidManifest.xml with both network config and cleartext traffic
-
FileUtils.mkdir_p(debug_dir)
-
debug_manifest_path = File.join(debug_dir, 'AndroidManifest.xml')
-
-
debug_manifest = <<~XML
-
<?xml version="1.0" encoding="utf-8"?>
-
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
-
xmlns:tools="http://schemas.android.com/tools">
-
-
<!-- Debug-only configuration for hot reload -->
-
<application
-
android:networkSecurityConfig="@xml/network_security_config"
-
android:usesCleartextTraffic="true"
-
tools:targetApi="31">
-
</application>
-
-
</manifest>
-
XML
-
-
File.write(debug_manifest_path, debug_manifest)
-
puts " Created: debug/AndroidManifest.xml with cleartext traffic enabled for debug builds only"
-
end
-
-
1
def get_local_ip
-
# Try to get WiFi IP first (common interface names)
-
1
require 'socket'
-
-
1
Socket.ip_address_list.each do |addr|
-
9
if addr.ipv4? && !addr.ipv4_loopback? && !addr.ipv4_multicast?
-
1
return addr.ip_address
-
end
-
end
-
-
nil
-
rescue
-
nil
-
end
-
-
1
def create_sample_layouts
-
1
puts "Creating sample layouts..."
-
-
# Create main.json
-
1
source_dir = @config['source_directory'] || 'src/main'
-
1
main_layout = File.join(source_dir, 'assets/Layouts/main.json')
-
-
1
content = <<~JSON
-
{
-
"type": "SafeAreaView",
-
"background": "#FFFFFF",
-
"child": [
-
{
-
"type": "View",
-
"orientation": "vertical",
-
"padding": 16,
-
"child": [
-
{
-
"type": "Label",
-
"text": "Welcome to KotlinJsonUI",
-
"fontSize": 24,
-
"fontWeight": "bold",
-
"fontColor": "#000000",
-
"marginBottom": 20
-
},
-
{
-
"type": "Label",
-
"text": "Build native Android UIs with JSON",
-
"fontSize": 16,
-
"fontColor": "#666666",
-
"marginBottom": 30
-
},
-
{
-
"type": "Button",
-
"text": "Get Started",
-
"onclick": "getStarted",
-
"background": "#6200EE",
-
"fontColor": "#FFFFFF",
-
"padding": [12, 24],
-
"cornerRadius": 8
-
}
-
]
-
}
-
],
-
"data": [
-
{
-
"name": "title",
-
"class": "String",
-
"defaultValue": "'Welcome'"
-
}
-
]
-
}
-
JSON
-
-
1
FileUtils.mkdir_p(File.dirname(main_layout))
-
1
File.write(main_layout, content) unless File.exist?(main_layout)
-
1
puts " Created: main.json" unless File.exist?(main_layout)
-
-
# Create sample style
-
1
source_dir = @config['source_directory'] || 'src/main'
-
1
button_style = File.join(source_dir, 'assets/Styles/primary_button.json')
-
-
1
style_content = <<~JSON
-
{
-
"background": "#6200EE",
-
"fontColor": "#FFFFFF",
-
"fontSize": 16,
-
"fontWeight": "medium",
-
"padding": [12, 24],
-
"cornerRadius": 8
-
}
-
JSON
-
-
1
FileUtils.mkdir_p(File.dirname(button_style))
-
1
File.write(button_style, style_content) unless File.exist?(button_style)
-
1
puts " Created: primary_button.json style" unless File.exist?(button_style)
-
end
-
-
1
def package_path(subpath)
-
3
source_dir = @config['source_directory'] || 'src/main'
-
3
package_dirs = @package_name.gsub('.', '/')
-
3
File.join(source_dir, "kotlin/#{package_dirs}/#{subpath}")
-
end
-
-
1
def find_app_gradle_file
-
# Look for app/build.gradle or app/build.gradle.kts
-
4
candidates = [
-
'app/build.gradle.kts',
-
'app/build.gradle',
-
'build.gradle.kts',
-
'build.gradle'
-
]
-
-
4
project_root = Core::ProjectFinder.project_dir || Dir.pwd
-
-
4
candidates.each do |candidate|
-
11
path = File.join(project_root, candidate)
-
11
return path if File.exist?(path)
-
end
-
-
nil
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'json'
-
1
require_relative '../core/config_manager'
-
1
require_relative '../core/project_finder'
-
-
1
module KjuiTools
-
1
module Compose
-
1
class StyleLoader
-
1
class << self
-
1
def load_and_merge(json_data)
-
41
return json_data unless json_data.is_a?(Hash)
-
-
# Load style if specified
-
40
if json_data['style']
-
4
style_data = load_style(json_data['style'])
-
4
if style_data
-
# Merge style data with component data
-
# Component data takes precedence over style data
-
3
merged_data = style_data.merge(json_data)
-
# Remove the style key from the merged data
-
3
merged_data.delete('style')
-
3
json_data = merged_data
-
end
-
end
-
-
# Process children recursively
-
40
if json_data['child']
-
13
if json_data['child'].is_a?(Array)
-
23
json_data['child'] = json_data['child'].map { |child| load_and_merge(child) }
-
else
-
2
json_data['child'] = load_and_merge(json_data['child'])
-
end
-
end
-
-
# Process includes
-
40
if json_data['include']
-
2
json_data = process_include(json_data)
-
end
-
-
40
json_data
-
end
-
-
1
private
-
-
1
def load_style(style_name)
-
4
config = Core::ConfigManager.load_config
-
4
project_path = Core::ProjectFinder.get_full_source_path || Dir.pwd
-
4
source_dir = config['source_directory'] || 'src/main'
-
4
source_path = File.join(project_path, source_dir)
-
4
styles_dir = File.join(source_path, config['styles_directory'] || 'assets/Styles')
-
-
4
style_file = File.join(styles_dir, "#{style_name}.json")
-
-
4
return nil unless File.exist?(style_file)
-
-
begin
-
4
style_content = File.read(style_file)
-
4
style_data = JSON.parse(style_content)
-
-
# Recursively load and merge styles in the style file
-
3
load_and_merge(style_data)
-
1
rescue JSON::ParserError => e
-
1
puts "Warning: Failed to parse style file #{style_file}: #{e.message}"
-
1
nil
-
end
-
end
-
-
1
def process_include(json_data)
-
# For Compose generation, don't expand includes inline
-
# They should be handled as component calls in compose_builder
-
2
json_data
-
end
-
end
-
end
-
end
-
end
-
#!/usr/bin/env ruby
-
-
1
require 'json'
-
-
1
module KjuiTools
-
1
module Core
-
# Validates JSON component attributes against defined schemas
-
# Used by both XML and Compose converters
-
1
class AttributeValidator
-
1
attr_reader :definitions, :warnings, :infos
-
1
attr_accessor :mode, :styles_dir
-
-
# Valid modes for this platform
-
1
MODES = [:xml, :compose, :dynamic, :all].freeze
-
-
# Current platform identifier
-
1
PLATFORM = 'kotlin'.freeze
-
-
# All supported platforms across JsonUI libraries
-
1
ALL_PLATFORMS = ['swift', 'kotlin', 'react'].freeze
-
-
1
def initialize(mode = :all, styles_dir = nil)
-
121
@definitions = load_definitions
-
121
@warnings = []
-
121
@infos = []
-
121
@mode = mode
-
121
@styles_dir = styles_dir
-
121
@styles_cache = {}
-
end
-
-
# Validate a component and return warnings
-
# @param component [Hash] The component to validate
-
# @param component_type [String] The type of component (e.g., "Label", "TextField")
-
# @param parent_orientation [String] The parent's orientation ('horizontal' or 'vertical')
-
# @return [Array<String>] Array of warning messages
-
1
def validate(component, component_type = nil, parent_orientation = nil)
-
89
@warnings = []
-
89
@infos = []
-
-
# Merge style attributes before validation
-
89
merged_component = merge_style_attributes(component)
-
-
89
type = component_type || merged_component['type']
-
-
89
return @warnings unless type
-
-
# Get valid attributes for this component type
-
89
valid_attrs = get_valid_attributes(type)
-
-
# Check each attribute in the merged component
-
89
merged_component.each do |key, value|
-
# Skip internal/structural attributes (including _ prefixed internal flags)
-
385
next if key == 'type' || key == 'child' || key == 'children' || key.start_with?('_')
-
-
293
if valid_attrs.key?(key)
-
288
attr_def = valid_attrs[key]
-
# Check platform compatibility first
-
288
if platform_compatible?(attr_def)
-
# Check mode compatibility
-
286
if mode_compatible?(attr_def)
-
# Validate attribute value
-
286
validate_attribute(key, value, attr_def, type)
-
else
-
# Attribute not supported in current mode - log as info
-
add_mode_info(key, attr_def, type)
-
end
-
else
-
# Attribute for other platform - log as info
-
2
add_platform_info(key, attr_def, type)
-
end
-
else
-
# Unknown attribute
-
5
add_warning("Unknown attribute '#{key}' for component type '#{type}'")
-
end
-
end
-
-
# Check for required attributes (only for current platform)
-
89
valid_attrs.each do |attr_name, attr_def|
-
13249
next unless platform_compatible?(attr_def)
-
8489
if attr_def['required'] && !merged_component.key?(attr_name)
-
# Skip width/height required check if weight is set and parent orientation allows it
-
48
next if skip_dimension_required?(attr_name, merged_component, parent_orientation)
-
-
48
add_warning("Required attribute '#{attr_name}' is missing for component type '#{type}'")
-
end
-
end
-
-
89
@warnings
-
end
-
-
# Print all warnings to console
-
1
def print_warnings
-
@warnings.each do |warning|
-
puts "\e[33m⚠️ [KJUI Warning] #{warning}\e[0m"
-
end
-
end
-
-
# Print all info messages to console
-
1
def print_infos
-
@infos.each do |info|
-
puts "\e[36mℹ️ [KJUI Info] #{info}\e[0m"
-
end
-
end
-
-
# Check if there are any warnings
-
1
def has_warnings?
-
2
!@warnings.empty?
-
end
-
-
# Check if there are any info messages
-
1
def has_infos?
-
!@infos.empty?
-
end
-
-
1
private
-
-
1
def load_definitions
-
121
definitions_path = File.join(File.dirname(__FILE__), 'attribute_definitions.json')
-
121
base_definitions = if File.exist?(definitions_path)
-
121
JSON.parse(File.read(definitions_path))
-
else
-
puts "\e[31m[KJUI Error] attribute_definitions.json not found at #{definitions_path}\e[0m"
-
{}
-
end
-
-
# Load and merge extension attribute definitions
-
121
extension_definitions = load_extension_definitions
-
121
merge_definitions(base_definitions, extension_definitions)
-
end
-
-
# Load extension attribute definitions from the extensions directory
-
1
def load_extension_definitions
-
121
extension_defs = {}
-
-
# Check for extension definitions in various locations
-
extension_paths = [
-
# Main KotlinJsonUI structure
-
121
File.join(Dir.pwd, 'kjui_tools', 'lib', 'compose', 'components', 'extensions', 'attribute_definitions'),
-
# Test app structure
-
File.join(Dir.pwd, 'app', 'kjui_tools', 'lib', 'compose', 'components', 'extensions', 'attribute_definitions')
-
]
-
-
121
extension_paths.each do |ext_dir|
-
242
next unless File.directory?(ext_dir)
-
-
118
Dir.glob(File.join(ext_dir, '*.json')).each do |file|
-
begin
-
5
component_defs = JSON.parse(File.read(file))
-
5
extension_defs.merge!(component_defs)
-
rescue JSON::ParserError => e
-
puts "\e[33m[KJUI Warning] Failed to parse extension definition #{file}: #{e.message}\e[0m"
-
end
-
end
-
end
-
-
121
extension_defs
-
end
-
-
# Merge extension definitions into base definitions
-
1
def merge_definitions(base, extensions)
-
121
extensions.each do |key, value|
-
5
if base.key?(key) && base[key].is_a?(Hash) && value.is_a?(Hash)
-
# Merge attributes for existing component types
-
base[key] = base[key].merge(value)
-
else
-
# Add new component type definitions
-
5
base[key] = value
-
end
-
end
-
121
base
-
end
-
-
# Get valid attributes for a component type (common + type-specific)
-
1
def get_valid_attributes(type)
-
89
attrs = {}
-
-
# Add common attributes
-
89
attrs.merge!(@definitions['common'] || {})
-
-
# Map component type to definition key
-
89
def_key = map_type_to_definition(type)
-
-
# Add type-specific attributes
-
89
if @definitions[def_key]
-
89
attrs.merge!(@definitions[def_key])
-
end
-
-
89
attrs
-
end
-
-
# Map JSON type to definition key
-
1
def map_type_to_definition(type)
-
89
case type
-
when 'Label', 'Text'
-
17
'Label'
-
when 'TextField', 'EditText'
-
11
'TextField'
-
when 'TextView', 'MultiLineEditText'
-
'TextView'
-
when 'Button'
-
1
'Button'
-
when 'Image', 'ImageView'
-
1
'Image'
-
when 'NetworkImage', 'NetworkImageView'
-
'NetworkImage'
-
when 'CircleImage', 'CircleImageView'
-
'CircleImage'
-
when 'SelectBox', 'Spinner', 'DatePicker'
-
1
'SelectBox'
-
when 'Toggle', 'Switch'
-
7
'Toggle'
-
when 'CheckBox', 'Check'
-
5
type == 'CheckBox' ? 'CheckBox' : 'Check'
-
when 'Radio', 'RadioButton', 'RadioGroup'
-
1
'Radio'
-
when 'Segment', 'SegmentedControl', 'TabLayout'
-
'Segment'
-
when 'Slider', 'SeekBar'
-
'Slider'
-
when 'Progress', 'ProgressBar'
-
'Progress'
-
when 'Indicator', 'ActivityIndicator'
-
'Indicator'
-
when 'View', 'Container', 'SafeAreaView', 'LinearLayout', 'RelativeLayout', 'FrameLayout',
-
'VStack', 'HStack', 'ZStack', 'Column', 'Row', 'Box'
-
38
'View'
-
when 'ScrollView', 'Scroll'
-
1
'ScrollView'
-
when 'Collection', 'CollectionView', 'RecyclerView', 'LazyGrid', 'Grid'
-
2
'Collection'
-
when 'Table', 'TableView', 'ListView', 'LazyColumn'
-
'Table'
-
when 'GradientView'
-
'GradientView'
-
when 'Blur', 'BlurView'
-
'Blur'
-
when 'IconLabel'
-
'IconLabel'
-
when 'Web', 'WebView'
-
'Web'
-
when 'TabView'
-
'TabView'
-
when 'ConstraintLayout'
-
'View'
-
else
-
4
type
-
end
-
end
-
-
# Validate a single attribute value
-
1
def validate_attribute(name, value, definition, component_type, path = nil)
-
291
return unless definition
-
-
291
current_path = path ? "#{path}.#{name}" : name
-
-
# Check for invalid binding syntax
-
291
check_invalid_binding_syntax(value, current_path, component_type)
-
-
# Check if value is a binding expression
-
291
is_binding = value.is_a?(String) && value.start_with?('@{') && value.end_with?('}')
-
-
# Skip validation for binding expressions
-
291
return if is_binding
-
-
# Check type
-
260
expected_types = Array(definition['type'])
-
260
actual_type = get_value_type(value)
-
-
260
unless type_matches?(actual_type, expected_types, value, definition)
-
3
add_warning("Attribute '#{current_path}' in '#{component_type}' expects #{format_expected_types(expected_types)}, got #{actual_type}")
-
3
return # Don't validate nested properties if type is wrong
-
end
-
-
# Check enum values
-
257
if definition['enum']
-
20
validate_enum_value(value, definition['enum'], current_path, component_type)
-
end
-
-
# Check min/max for numbers
-
257
if actual_type == 'number'
-
37
if definition['min'] && value < definition['min']
-
1
add_warning("Attribute '#{current_path}' in '#{component_type}' value #{value} is less than minimum #{definition['min']}")
-
end
-
37
if definition['max'] && value > definition['max']
-
1
add_warning("Attribute '#{current_path}' in '#{component_type}' value #{value} is greater than maximum #{definition['max']}")
-
end
-
end
-
-
# Validate nested object properties
-
257
if actual_type == 'object' && definition['properties']
-
2
validate_nested_object(value, definition['properties'], component_type, current_path)
-
end
-
-
# Validate array items
-
257
if actual_type == 'array' && definition['items']
-
validate_array_items(value, definition['items'], component_type, current_path)
-
end
-
end
-
-
# Validate enum value (supports both single values and arrays)
-
1
def validate_enum_value(value, enum_values, path, component_type)
-
20
if value.is_a?(Array)
-
# For array values, check each element
-
11
invalid_values = value.reject { |v| enum_values.include?(v) }
-
4
unless invalid_values.empty?
-
2
add_warning("Attribute '#{path}' in '#{component_type}' has invalid value(s) '#{invalid_values.inspect}'. Valid values: #{enum_values.join(', ')}")
-
end
-
else
-
# For single values
-
16
unless enum_values.include?(value)
-
4
add_warning("Attribute '#{path}' in '#{component_type}' has invalid value '#{value}'. Valid values: #{enum_values.join(', ')}")
-
end
-
end
-
end
-
-
# Format expected types for error messages
-
1
def format_expected_types(expected_types)
-
3
formatted = expected_types.map do |type|
-
6
if type.is_a?(Hash) && type['enum']
-
1
"enum(#{type['enum'].join(', ')})"
-
else
-
5
type
-
end
-
end
-
3
formatted.join(' or ')
-
end
-
-
# Validate nested object properties
-
1
def validate_nested_object(obj, properties, component_type, path)
-
2
return unless obj.is_a?(Hash)
-
-
2
obj.each do |key, value|
-
5
if properties.key?(key)
-
5
validate_attribute(key, value, properties[key], component_type, path)
-
else
-
add_warning("Unknown property '#{path}.#{key}' in '#{component_type}'")
-
end
-
end
-
end
-
-
# Validate array items
-
1
def validate_array_items(arr, item_def, component_type, path)
-
return unless arr.is_a?(Array)
-
-
arr.each_with_index do |item, index|
-
item_path = "#{path}[#{index}]"
-
-
if item_def['type'] == 'object' && item_def['properties']
-
if item.is_a?(Hash)
-
validate_nested_object(item, item_def['properties'], component_type, item_path)
-
else
-
add_warning("#{item_path} in '#{component_type}' expects object, got #{get_value_type(item)}")
-
end
-
else
-
# Simple type validation for array items
-
expected_types = Array(item_def['type'])
-
actual_type = get_value_type(item)
-
unless type_matches?(actual_type, expected_types, item, item_def)
-
add_warning("#{item_path} in '#{component_type}' expects #{expected_types.join(' or ')}, got #{actual_type}")
-
end
-
end
-
end
-
end
-
-
1
def get_value_type(value)
-
260
case value
-
when String
-
202
'string'
-
when Integer, Float
-
37
'number'
-
when TrueClass, FalseClass
-
12
'boolean'
-
when Array
-
7
'array'
-
when Hash
-
2
'object'
-
when NilClass
-
'null'
-
else
-
'unknown'
-
end
-
end
-
-
1
def type_matches?(actual, expected_types, value, definition = nil)
-
260
expected_types.any? do |expected|
-
391
case expected
-
when 'string'
-
85
actual == 'string'
-
when 'number'
-
162
actual == 'number'
-
when 'boolean'
-
12
actual == 'boolean'
-
when 'array'
-
7
actual == 'array'
-
when 'object'
-
2
actual == 'object'
-
when 'binding'
-
# binding type requires @{propertyName} format
-
2
actual == 'string' && value.is_a?(String) && value.start_with?('@{') && value.end_with?('}')
-
when 'any'
-
true
-
when Hash
-
# Handle enum type definition: {"enum": [...]}
-
121
if expected['enum']
-
121
if actual == 'string'
-
121
expected['enum'].include?(value)
-
elsif actual == 'array'
-
# For array values, check if all elements are in enum
-
value.is_a?(Array) && value.all? { |v| expected['enum'].include?(v) }
-
else
-
false
-
end
-
else
-
false
-
end
-
else
-
# For union types or special cases
-
actual == expected
-
end
-
end
-
end
-
-
1
def add_warning(message)
-
68
@warnings << message unless @warnings.include?(message)
-
end
-
-
1
def add_info(message)
-
2
@infos << message unless @infos.include?(message)
-
end
-
-
# Check for invalid binding syntax (starts with @{ but doesn't end with })
-
1
def check_invalid_binding_syntax(value, path, component_type)
-
291
return unless value.is_a?(String)
-
233
return unless value.start_with?('@{')
-
35
return if value.end_with?('}')
-
-
4
add_warning("Attribute '#{path}' in '#{component_type}' has invalid binding syntax (starts with '@{' but doesn't end with '}')")
-
end
-
-
# Check if width/height required warning should be skipped
-
# When weight is set, the dimension in the parent's orientation direction is not required
-
# - parent orientation: horizontal -> width not required if weight is set
-
# - parent orientation: vertical -> height not required if weight is set
-
1
def skip_dimension_required?(attr_name, component, parent_orientation)
-
48
return false unless component.key?('weight')
-
return false unless %w[width height].include?(attr_name)
-
-
case parent_orientation
-
when 'horizontal'
-
# In horizontal layout, weight determines width
-
attr_name == 'width'
-
when 'vertical'
-
# In vertical layout, weight determines height
-
attr_name == 'height'
-
else
-
# Default orientation is vertical, so height is determined by weight
-
attr_name == 'height'
-
end
-
end
-
-
# Check if attribute is compatible with current platform
-
# Attributes with platform specified for other platforms are silently skipped
-
1
def platform_compatible?(attr_def)
-
13537
return true unless attr_def['platform']
-
-
4762
attr_platforms = Array(attr_def['platform'])
-
4762
attr_platforms.include?(PLATFORM) || attr_platforms.include?('all')
-
end
-
-
# Check if attribute is compatible with current mode
-
1
def mode_compatible?(attr_def)
-
286
return true if @mode == :all
-
17
return true unless attr_def['mode']
-
-
4
attr_modes = Array(attr_def['mode'])
-
4
attr_modes.include?(@mode.to_s) || attr_modes.include?('all')
-
end
-
-
# Add info for mode-incompatible attribute (not an error, just informational)
-
1
def add_mode_info(attr_name, attr_def, component_type)
-
attr_modes = Array(attr_def['mode'])
-
mode_str = attr_modes.map { |m| m.capitalize }.join('/')
-
current_mode_str = @mode.to_s.capitalize
-
-
add_info("Attribute '#{attr_name}' in '#{component_type}' is for #{mode_str} mode (current: #{current_mode_str})")
-
end
-
-
# Add info for platform-specific attribute (not an error, just informational)
-
1
def add_platform_info(attr_name, attr_def, component_type)
-
2
attr_platforms = Array(attr_def['platform'])
-
4
platform_str = attr_platforms.map { |p| p.capitalize }.join('/')
-
-
2
add_info("Attribute '#{attr_name}' in '#{component_type}' is for #{platform_str} platform (current: #{PLATFORM.capitalize})")
-
end
-
-
# Merge style attributes into component for validation
-
# Style provides base attributes, component attributes override
-
# @param component [Hash] The component to process
-
# @return [Hash] Component with style attributes merged
-
1
def merge_style_attributes(component)
-
89
return component unless component.is_a?(Hash)
-
89
return component unless component['style']
-
-
5
style_name = component['style']
-
5
style_data = load_style_file(style_name)
-
-
5
return component unless style_data
-
-
# Create merged result: style as base, component overrides
-
3
component_without_style = component.dup
-
3
component_without_style.delete('style')
-
-
# If component has type, ignore style's type
-
3
style_data_for_merge = style_data.dup
-
3
if component_without_style['type']
-
3
style_data_for_merge.delete('type')
-
end
-
-
# Deep merge: style as base, component properties override
-
3
deep_merge(style_data_for_merge, component_without_style)
-
end
-
-
# Load style file from styles directory
-
# @param style_name [String] Name of the style file (without .json extension)
-
# @return [Hash, nil] Parsed style data or nil if not found
-
1
def load_style_file(style_name)
-
5
return @styles_cache[style_name] if @styles_cache.key?(style_name)
-
-
5
styles_dir = determine_styles_dir
-
5
return nil unless styles_dir
-
-
5
style_file = File.join(styles_dir, "#{style_name}.json")
-
5
return nil unless File.exist?(style_file)
-
-
begin
-
3
style_data = JSON.parse(File.read(style_file))
-
3
@styles_cache[style_name] = style_data
-
3
style_data
-
rescue JSON::ParserError
-
nil
-
end
-
end
-
-
# Determine the styles directory path
-
# @return [String, nil] Path to styles directory or nil
-
1
def determine_styles_dir
-
5
return @styles_dir if @styles_dir && Dir.exist?(@styles_dir)
-
-
# Try common locations for Android projects
-
possible_dirs = [
-
1
File.join(Dir.pwd, 'src', 'main', 'assets', 'Styles'),
-
File.join(Dir.pwd, 'app', 'src', 'main', 'assets', 'Styles'),
-
File.join(Dir.pwd, 'Styles'),
-
File.join(Dir.pwd, 'styles'),
-
File.join(Dir.pwd, 'Layouts', 'Styles'),
-
File.join(Dir.pwd, 'Layouts', 'styles')
-
]
-
-
2
possible_dirs.find { |dir| Dir.exist?(dir) }
-
end
-
-
# Deep merge two hashes
-
# @param hash1 [Hash] Base hash
-
# @param hash2 [Hash] Override hash
-
# @return [Hash] Merged hash
-
1
def deep_merge(hash1, hash2)
-
3
return hash2 if hash1.nil?
-
3
return hash1 if hash2.nil?
-
-
3
result = hash1.dup
-
-
3
hash2.each do |key, value|
-
4
if result[key].is_a?(Hash) && value.is_a?(Hash)
-
result[key] = deep_merge(result[key], value)
-
else
-
4
result[key] = value
-
end
-
end
-
-
3
result
-
end
-
end
-
end
-
end
-
#!/usr/bin/env ruby
-
-
1
require 'json'
-
1
require 'set'
-
-
1
module KjuiTools
-
1
module Core
-
# Validates binding expressions in JSON layouts
-
# Warns when bindings contain business logic that should be in ViewModel
-
1
class BindingValidator
-
1
attr_reader :warnings
-
-
# Patterns that indicate business logic in bindings
-
BUSINESS_LOGIC_PATTERNS = [
-
# Ternary operators (Kotlin: if-else expression or ternary-like)
-
{
-
1
pattern: /\?.*:/,
-
message: "ternary operator (?:) - move condition logic to ViewModel"
-
},
-
# Kotlin if expression
-
{
-
pattern: /\bif\s*\(/,
-
message: "if expression - move condition logic to ViewModel"
-
},
-
# Kotlin when expression
-
{
-
pattern: /\bwhen\s*[({]/,
-
message: "when expression - move logic to ViewModel"
-
},
-
# Comparison operators
-
{
-
pattern: /[<>=!]=|[<>]/,
-
message: "comparison operator - move to ViewModel computed property"
-
},
-
# Arithmetic operators (but allow simple negation)
-
{
-
pattern: /(?<![a-zA-Z_])[+\/*%]|(?<![a-zA-Z_0-9])-(?![a-zA-Z_0-9}])/,
-
message: "arithmetic operator - compute value in ViewModel"
-
},
-
# Logical operators
-
{
-
pattern: /&&|\|\|/,
-
message: "logical operator (&&, ||) - move logic to ViewModel"
-
},
-
# Elvis operator (null coalescing)
-
{
-
pattern: /\?:/,
-
message: "elvis operator (?:) - handle null in ViewModel"
-
},
-
# Method calls with arguments (but allow simple property access)
-
{
-
pattern: /\.\w+\([^)]+\)/,
-
message: "method call with arguments - move to ViewModel"
-
},
-
# String interpolation
-
{
-
pattern: /\$\{|\$[a-zA-Z]/,
-
message: "string interpolation - compose string in ViewModel"
-
},
-
# Array subscript with complex expression
-
{
-
pattern: /\[[^\]]*[+\-*\/<>=]/,
-
message: "complex array subscript - simplify in ViewModel"
-
},
-
# Type casting
-
{
-
pattern: /\s+as[?\s]+\w+/,
-
message: "type casting - handle type conversion in ViewModel"
-
},
-
# Not-null assertion
-
{
-
pattern: /!!/,
-
message: "not-null assertion (!!) - handle nullability safely in ViewModel"
-
},
-
# Lambda expressions
-
{
-
pattern: /\{[^}]*->[^}]*\}/,
-
message: "lambda expression - move to ViewModel"
-
},
-
# Range operators
-
{
-
pattern: /\.\.|\s+until\s+|\s+downTo\s+/,
-
message: "range operator - create range in ViewModel"
-
},
-
# let/run/apply/also blocks
-
{
-
pattern: /\.(let|run|apply|also|with)\s*\{/,
-
message: "scope function - move logic to ViewModel"
-
}
-
].freeze
-
-
# Allowed simple patterns that look like logic but are acceptable
-
1
ALLOWED_PATTERNS = [
-
# Simple property access (including safe call)
-
/^@\{[a-zA-Z_][a-zA-Z0-9_]*(\??\.[a-zA-Z_][a-zA-Z0-9_]*)*\}$/,
-
# Simple negation for boolean
-
/^@\{![a-zA-Z_][a-zA-Z0-9_]*\}$/,
-
# Simple array access with constant index
-
/^@\{[a-zA-Z_][a-zA-Z0-9_]*\[\d+\]\}$/,
-
# Action bindings (callbacks)
-
/^@\{on[A-Z][a-zA-Z0-9_]*\}$/,
-
# data. prefix for accessing data properties (e.g., @{data.name} in Collection cells)
-
/^@\{data\.[a-zA-Z_][a-zA-Z0-9_.]*\}$/
-
].freeze
-
-
1
def initialize
-
38
@warnings = []
-
38
@data_properties = Set.new
-
end
-
-
# Validate all bindings in a JSON component tree
-
# @param json_data [Hash] The root component
-
# @param file_name [String] The file name for error messages
-
# @return [Array<String>] Array of warning messages
-
1
def validate(json_data, file_name = nil)
-
35
@warnings = []
-
35
@current_file = file_name
-
35
@data_properties = Set.new
-
-
# First pass: collect all data property names
-
35
collect_data_properties(json_data)
-
-
# Second pass: validate bindings
-
35
validate_component(json_data)
-
35
@warnings
-
end
-
-
# Check if there are any warnings
-
1
def has_warnings?
-
2
!@warnings.empty?
-
end
-
-
# Print all warnings to stdout
-
1
def print_warnings
-
@warnings.each do |warning|
-
puts "\e[33m[KJUI Binding Warning]\e[0m #{warning}"
-
end
-
end
-
-
# Check a single binding expression
-
# @param binding_expr [String] The binding expression (without @{ })
-
# @param attribute_name [String] The attribute name
-
# @param component_type [String] The component type
-
# @return [Array<String>] Array of warning messages
-
1
def check_binding(binding_expr, attribute_name, component_type)
-
35
warnings = []
-
-
# Check if it's allowed simple pattern
-
35
full_binding = "@{#{binding_expr}}"
-
35
return warnings if allowed_pattern?(full_binding)
-
-
# Check for business logic patterns
-
14
BUSINESS_LOGIC_PATTERNS.each do |rule|
-
210
if binding_expr.match?(rule[:pattern])
-
16
context = @current_file ? "[#{@current_file}] " : ""
-
16
warnings << "#{context}Binding '@{#{binding_expr}}' in '#{component_type}.#{attribute_name}' contains #{rule[:message]}"
-
end
-
end
-
-
14
warnings
-
end
-
-
1
private
-
-
# Collect all data property names from the component tree
-
1
def collect_data_properties(component)
-
48
return unless component.is_a?(Hash)
-
-
# Check for data declarations
-
48
if component['data'].is_a?(Array)
-
22
component['data'].each do |data_item|
-
29
next unless data_item.is_a?(Hash)
-
# Skip ViewModel class declarations (they have 'class' key but no 'name')
-
# e.g., { "class": "MyViewModel" } - this is a ViewModel class, not a property
-
# But include property declarations: { "name": "userName", "class": "String" }
-
29
next if data_item['class'] && !data_item['name']
-
# Add property name to the set
-
28
if data_item['name']
-
28
@data_properties << data_item['name']
-
end
-
end
-
end
-
-
# Recurse into children
-
48
children = component['child'] || component['children'] || []
-
48
children = [children] unless children.is_a?(Array)
-
58
children.each { |child| collect_data_properties(child) if child.is_a?(Hash) }
-
-
# Recurse into sections
-
48
if component['sections'].is_a?(Array)
-
2
component['sections'].each do |section|
-
2
next unless section.is_a?(Hash)
-
2
['header', 'footer', 'cell'].each do |key|
-
6
collect_data_properties(section[key]) if section[key].is_a?(Hash)
-
end
-
end
-
end
-
end
-
-
1
def validate_component(component, parent_type = nil)
-
48
return unless component.is_a?(Hash)
-
-
48
component_type = component['type'] || parent_type || 'Unknown'
-
-
# Check each attribute for bindings
-
48
component.each do |key, value|
-
122
next if key == 'type' || key == 'child' || key == 'children' || key == 'sections'
-
61
next if key == 'data' || key == 'generatedBy' || key == 'include' || key == 'style'
-
-
37
check_value_for_bindings(value, key, component_type)
-
end
-
-
# Validate children
-
48
children = component['child'] || component['children'] || []
-
48
children = [children] unless children.is_a?(Array)
-
58
children.each { |child| validate_component(child, component_type) if child.is_a?(Hash) }
-
-
# Validate sections (Collection/Table)
-
48
if component['sections'].is_a?(Array)
-
2
component['sections'].each do |section|
-
2
next unless section.is_a?(Hash)
-
2
['header', 'footer', 'cell'].each do |key|
-
6
validate_component(section[key], component_type) if section[key].is_a?(Hash)
-
end
-
end
-
end
-
end
-
-
1
def check_value_for_bindings(value, attribute_name, component_type)
-
37
case value
-
when String
-
37
if value.start_with?('@{') && value.end_with?('}')
-
33
binding_expr = value[2..-2] # Remove @{ and }
-
33
binding_warnings = check_binding(binding_expr, attribute_name, component_type)
-
33
@warnings.concat(binding_warnings)
-
-
# Check if binding variables are defined in data
-
33
check_undefined_variables(binding_expr, attribute_name, component_type)
-
end
-
when Hash
-
value.each do |k, v|
-
check_value_for_bindings(v, "#{attribute_name}.#{k}", component_type)
-
end
-
when Array
-
value.each_with_index do |item, index|
-
check_value_for_bindings(item, "#{attribute_name}[#{index}]", component_type)
-
end
-
end
-
end
-
-
# Check if variables in binding expression are defined in data
-
1
def check_undefined_variables(binding_expr, attribute_name, component_type)
-
# Skip data. prefix bindings (Collection cell bindings)
-
33
return if binding_expr.start_with?('data.')
-
-
# Extract variable names from the binding expression
-
31
variables = extract_variables(binding_expr)
-
-
31
variables.each do |var|
-
34
unless @data_properties.include?(var)
-
8
context = @current_file ? "[#{@current_file}] " : ""
-
8
@warnings << "#{context}Binding variable '#{var}' in '#{component_type}.#{attribute_name}' is not defined in data. Add: { \"class\": \"#{infer_type(var, attribute_name)}\", \"name\": \"#{var}\" }"
-
end
-
end
-
end
-
-
# Extract variable names from binding expression
-
1
def extract_variables(binding_expr)
-
31
variables = Set.new
-
-
# Remove string literals to avoid false positives
-
31
expr = binding_expr.gsub(/'[^']*'/, '').gsub(/"[^"]*"/, '')
-
-
# Match variable names (identifiers that are not keywords or literals)
-
# Skip: numbers, true, false, null, visible, gone
-
31
keywords = %w[true false null visible gone]
-
-
31
expr.scan(/\b([a-zA-Z_][a-zA-Z0-9_]*)\b/).flatten.each do |match|
-
34
next if keywords.include?(match)
-
34
next if match =~ /^\d/ # Skip if starts with digit
-
34
variables << match
-
end
-
-
31
variables.to_a
-
end
-
-
# Infer type from variable name and attribute context
-
# Returns Kotlin type format
-
1
def infer_type(var_name, attribute_name)
-
# onClick, onXxx -> (() -> Unit)? (Kotlin callback type)
-
8
return '(() -> Unit)?' if var_name.start_with?('on') && var_name[2]&.match?(/[A-Z]/)
-
-
# xxxItems, xxxOptions, xxxList -> List<Any>
-
7
return 'List<Any>' if var_name.end_with?('Items', 'Options', 'List', 'Args', 'Subcommands')
-
-
# isXxx, hasXxx, canXxx, shouldXxx -> Boolean
-
6
return 'Boolean' if var_name.start_with?('is', 'has', 'can', 'should')
-
-
# xxxVisibility -> String
-
5
return 'String' if var_name.end_with?('Visibility')
-
-
# xxxIndex, xxxCount, xxxTab -> Int
-
5
return 'Int' if var_name.end_with?('Index', 'Count', 'Tab')
-
-
# xxxMargin, xxxPadding -> Dp (Kotlin Compose)
-
4
return 'Dp' if var_name.end_with?('Margin', 'Padding')
-
-
# Based on attribute name
-
4
case attribute_name
-
when 'onClick', 'onValueChanged', 'onValueChange', 'onTap'
-
'(() -> Unit)?'
-
when 'items'
-
'CollectionDataSource'
-
when 'sections'
-
'List<Any>'
-
when 'visibility', 'text', 'fontColor', 'background'
-
4
'String'
-
when 'selectedIndex', 'width', 'height'
-
'Int'
-
when 'hidden', 'enabled', 'disabled'
-
'Boolean'
-
when 'topMargin', 'bottomMargin', 'leftMargin', 'rightMargin', 'startMargin', 'endMargin'
-
'Dp'
-
when 'paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight', 'paddingStart', 'paddingEnd'
-
'Dp'
-
else
-
'Any'
-
end
-
end
-
-
1
def allowed_pattern?(binding)
-
129
ALLOWED_PATTERNS.any? { |pattern| binding.match?(pattern) }
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'json'
-
1
require 'pathname'
-
-
1
module KjuiTools
-
1
module Core
-
1
class ConfigManager
-
1
CONFIG_FILE = 'kjui.config.json'
-
-
DEFAULT_CONFIG = {
-
1
'mode' => 'compose',
-
'project_name' => '',
-
'package_name' => 'com.example.app',
-
'source_directory' => 'app/src/main',
-
'layouts_directory' => 'assets/Layouts',
-
'styles_directory' => 'assets/Styles',
-
'view_directory' => 'kotlin/com/example/app/views',
-
'data_directory' => 'kotlin/com/example/app/data',
-
'viewmodel_directory' => 'kotlin/com/example/app/viewmodels',
-
'extension_directory' => 'library/src/main/kotlin/com/kotlinjsonui/extensions',
-
'adapter_directory' => 'library/src/main/kotlin/com/kotlinjsonui/adapters',
-
'custom_view_types' => {},
-
'compose' => {
-
'output_directory' => 'kotlin/com/example/app/generated'
-
},
-
'xml' => {
-
'bindings_directory' => 'java/com/example/app/bindings'
-
}
-
}.freeze
-
-
1
class << self
-
1
def load_config
-
133
config_path = find_config_file
-
-
133
base_config = if config_path && File.exist?(config_path)
-
begin
-
26
config_data = JSON.parse(File.read(config_path))
-
# Store the config directory for use by generators
-
25
config_data['_config_dir'] = File.dirname(config_path)
-
25
config_data
-
rescue JSON::ParserError => e
-
1
puts "Error parsing config file: #{e.message}"
-
1
{}
-
end
-
else
-
107
{}
-
end
-
-
# Merge with default config to ensure all keys exist
-
133
deep_merge(DEFAULT_CONFIG, base_config)
-
end
-
-
# Find config file in project
-
1
def find_config_file
-
# First check current directory
-
136
return CONFIG_FILE if File.exist?(CONFIG_FILE)
-
-
# Check subdirectories for kjui.config.json
-
109
Dir.glob(File.join(Dir.pwd, '**/kjui.config.json')).each do |config_path|
-
# Skip hidden directories and node_modules
-
1
next if config_path.include?('/.') || config_path.include?('/node_modules/')
-
1
return config_path
-
end
-
-
# Check parent directories up to 3 levels
-
108
current = Dir.pwd
-
108
3.times do
-
324
current = File.dirname(current)
-
324
config_path = File.join(current, CONFIG_FILE)
-
324
return config_path if File.exist?(config_path)
-
end
-
-
nil
-
end
-
-
1
def save_config(config)
-
3
File.write(CONFIG_FILE, JSON.pretty_generate(config))
-
end
-
-
# Deep merge two hashes
-
1
def deep_merge(hash1, hash2)
-
150
hash1.merge(hash2) do |key, old_val, new_val|
-
142
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
-
15
deep_merge(old_val, new_val)
-
else
-
127
new_val
-
end
-
end
-
end
-
-
1
def config_exists?
-
2
File.exist?(CONFIG_FILE)
-
end
-
-
1
def get(key, default = nil)
-
21
config = load_config
-
21
keys = key.split('.')
-
-
21
value = config
-
21
keys.each do |k|
-
22
value = value[k] if value.is_a?(Hash)
-
end
-
-
21
value || default
-
end
-
-
1
def set(key, value)
-
2
config = load_config
-
2
keys = key.split('.')
-
-
2
current = config
-
2
keys[0...-1].each do |k|
-
1
current[k] ||= {}
-
1
current = current[k]
-
end
-
-
2
current[keys.last] = value
-
2
save_config(config)
-
end
-
-
1
def detect_mode
-
# Check for Android project files
-
7
gradle_files = Dir.glob('build.gradle*')
-
7
settings_gradle = Dir.glob('settings.gradle*')
-
-
7
if gradle_files.any? || settings_gradle.any?
-
# Check if it's a Compose project
-
2
build_file = gradle_files.first
-
2
if build_file && File.exist?(build_file)
-
2
content = File.read(build_file)
-
2
if content.include?('compose') || content.include?('androidx.compose')
-
1
return 'compose'
-
end
-
end
-
-
# Default to XML for Android projects
-
1
return 'xml'
-
end
-
-
# Default mode
-
5
'all'
-
end
-
-
1
def project_type
-
4
mode = get('mode', detect_mode)
-
-
4
case mode
-
when 'compose'
-
1
'Jetpack Compose'
-
when 'xml'
-
1
'Android XML'
-
when 'all'
-
1
'Android (XML + Compose)'
-
else
-
1
'Unknown'
-
end
-
end
-
-
1
def source_path
-
7
get('source_directory', 'app/src/main')
-
end
-
-
1
def layouts_path
-
1
Pathname.new(source_path).join(get('layouts_directory', 'assets/Layouts'))
-
end
-
-
1
def styles_path
-
1
Pathname.new(source_path).join(get('styles_directory', 'assets/Styles'))
-
end
-
-
1
def view_path
-
1
Pathname.new(source_path).join(get('view_directory', 'java/com/example/app/ui'))
-
end
-
-
1
def data_path
-
1
Pathname.new(source_path).join(get('data_directory', 'java/com/example/app/data'))
-
end
-
-
1
def viewmodel_path
-
1
Pathname.new(source_path).join(get('viewmodel_directory', 'java/com/example/app/viewmodel'))
-
end
-
-
1
def generated_path
-
1
if get('mode') == 'compose'
-
1
compose_config = get('compose', {})
-
1
Pathname.new(source_path).join(compose_config['output_directory'] || 'java/com/example/app/generated')
-
else
-
Pathname.new(source_path).join(get('bindings_directory', 'java/com/example/app/bindings'))
-
end
-
end
-
end
-
end
-
end
-
end
-
#!/usr/bin/env ruby
-
-
1
require 'json'
-
-
1
class JsonLoader
-
1
def initialize(config)
-
32
@config = config
-
end
-
-
1
def load_layout(layout_name)
-
10
layout_file = find_layout_file(layout_name)
-
-
10
if layout_file && File.exist?(layout_file)
-
8
File.read(layout_file)
-
else
-
nil
-
end
-
end
-
-
1
def load_json(file_path)
-
2
if File.exist?(file_path)
-
1
File.read(file_path)
-
else
-
nil
-
end
-
end
-
-
1
private
-
-
1
def find_layout_file(layout_name)
-
# Remove .json extension if present
-
10
layout_name = layout_name.sub(/\.json$/, '')
-
-
10
project_path = @config['project_path'] || Dir.pwd
-
-
# Check multiple possible locations
-
possible_paths = [
-
10
File.join(project_path, 'src', 'main', 'assets', 'Layouts', "#{layout_name}.json"),
-
File.join(project_path, 'app', 'src', 'main', 'assets', 'Layouts', "#{layout_name}.json"),
-
File.join(project_path, 'Layouts', "#{layout_name}.json"),
-
File.join(project_path, "#{layout_name}.json")
-
]
-
-
32
possible_paths.find { |path| File.exist?(path) }
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module KjuiTools
-
1
module Core
-
1
class Logger
-
1
class << self
-
1
def info(message)
-
82
puts " #{message}"
-
end
-
-
1
def success(message)
-
4
puts "✅ #{message}"
-
end
-
-
1
def error(message)
-
2
puts "❌ #{message}"
-
end
-
-
1
def warn(message)
-
16
puts "⚠️ #{message}"
-
end
-
-
1
def debug(message)
-
73
puts "🔍 #{message}" if ENV['DEBUG']
-
end
-
-
1
def newline
-
1
puts
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'pathname'
-
1
require 'find'
-
-
1
module KjuiTools
-
1
module Core
-
1
class ProjectFinder
-
1
class << self
-
1
attr_accessor :project_dir, :project_file_path
-
-
1
def setup_paths
-
1
@project_dir = find_project_dir
-
1
@project_file_path = find_project_file
-
end
-
-
1
def find_project_dir
-
# Look for Android project indicators
-
4
current_dir = Dir.pwd
-
-
# Check current directory
-
4
return current_dir if android_project?(current_dir)
-
-
# Check parent directory
-
3
parent_dir = File.dirname(current_dir)
-
3
return parent_dir if android_project?(parent_dir)
-
-
# Default to current directory
-
2
current_dir
-
end
-
-
1
def find_project_file
-
# Look for main build.gradle or build.gradle.kts
-
4
gradle_files = Dir.glob('build.gradle*')
-
4
return gradle_files.first if gradle_files.any?
-
-
# Check parent directory
-
2
parent_gradle = Dir.glob('../build.gradle*')
-
2
return File.expand_path(parent_gradle.first) if parent_gradle.any?
-
-
nil
-
end
-
-
1
def find_source_directory
-
# Common Android source directory patterns
-
3
common_paths = [
-
'app/src/main',
-
'src/main',
-
'src',
-
'app'
-
]
-
-
3
project_root = @project_dir || Dir.pwd
-
-
3
common_paths.each do |path|
-
7
full_path = File.join(project_root, path)
-
7
return path if Dir.exist?(full_path)
-
end
-
-
# Try to find any src directory
-
1
Find.find(project_root) do |path|
-
1
if File.directory?(path) && File.basename(path) == 'src'
-
# Check if it contains main directory
-
main_path = File.join(path, 'main')
-
if Dir.exist?(main_path)
-
return Pathname.new(main_path).relative_path_from(Pathname.new(project_root)).to_s
-
end
-
return Pathname.new(path).relative_path_from(Pathname.new(project_root)).to_s
-
end
-
end
-
-
# Default
-
1
'app/src/main'
-
end
-
-
1
def get_full_source_path
-
54
@project_dir || Dir.pwd
-
end
-
-
1
def get_package_name
-
1
package_name
-
end
-
-
1
def package_name
-
# Try to detect package name from AndroidManifest.xml
-
4
manifest_paths = [
-
'app/src/main/AndroidManifest.xml',
-
'src/main/AndroidManifest.xml',
-
'AndroidManifest.xml'
-
]
-
-
4
project_root = @project_dir || Dir.pwd
-
-
4
manifest_paths.each do |path|
-
10
full_path = File.join(project_root, path)
-
10
if File.exist?(full_path)
-
1
content = File.read(full_path)
-
# Extract package name from manifest
-
1
if content =~ /package="([^"]+)"/
-
1
return $1
-
end
-
end
-
end
-
-
# Try to detect from build.gradle
-
3
gradle_files = Dir.glob('**/build.gradle*')
-
3
gradle_files.each do |gradle_file|
-
2
content = File.read(gradle_file)
-
# Look for namespace first (more reliable)
-
2
if content =~ /namespace\s*=\s*["']([^"']+)["']/
-
1
return $1
-
end
-
# Look for applicationId
-
1
if content =~ /applicationId\s*=\s*["']([^"']+)["']/
-
1
return $1
-
end
-
end
-
-
# Default package name
-
1
'com.example.app'
-
end
-
-
1
private
-
-
1
def android_project?(dir)
-
# Check for Android project indicators
-
7
indicators = [
-
'build.gradle',
-
'build.gradle.kts',
-
'settings.gradle',
-
'settings.gradle.kts',
-
'gradlew',
-
'app/build.gradle',
-
'app/build.gradle.kts'
-
]
-
-
46
indicators.any? { |indicator| File.exist?(File.join(dir, indicator)) }
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'json'
-
1
require 'fileutils'
-
1
require 'rexml/document'
-
1
require 'rexml/formatters/pretty'
-
1
require_relative '../logger'
-
-
1
module KjuiTools
-
1
module Core
-
1
module Resources
-
1
class ColorManager
-
1
def initialize(config, source_path, resources_dir)
-
76
@config = config
-
76
@source_path = source_path
-
76
@resources_dir = resources_dir
-
76
@colors_file = File.join(@resources_dir, 'colors.json')
-
76
@defined_colors_file = File.join(@resources_dir, 'defined_colors.json')
-
76
@extracted_colors = {}
-
76
@undefined_colors = {}
-
76
@colors_data = load_colors_json
-
76
@defined_colors_data = load_defined_colors_json
-
end
-
-
# Main process method called from ResourcesManager
-
1
def process_colors(processed_files, processed_count, skipped_count, config)
-
11
return if processed_files.empty?
-
-
10
Core::Logger.info "Extracting colors from #{processed_count} files (#{skipped_count} skipped)..."
-
-
# Extract colors from JSON files
-
10
extract_colors(processed_files)
-
-
# Save updated colors.json if there are new colors
-
10
save_colors_json if @extracted_colors.any?
-
-
# Save undefined colors to defined_colors.json
-
10
save_defined_colors_json if @undefined_colors.any?
-
-
# Generate ColorManager.kt if needed
-
10
generate_color_manager_kotlin if @config['resource_manager_directory']
-
end
-
-
# Apply extracted colors to color resources
-
1
def apply_to_color_assets
-
# Save any pending colors to colors.json
-
9
save_colors_json if @extracted_colors.any?
-
# Save undefined colors to defined_colors.json
-
9
save_defined_colors_json if @undefined_colors.any?
-
# Apply colors to Android colors.xml
-
9
apply_to_colors_xml
-
end
-
-
1
private
-
-
# Load existing colors.json file
-
1
def load_colors_json
-
85
return {} unless File.exist?(@colors_file)
-
-
begin
-
6
JSON.parse(File.read(@colors_file))
-
1
rescue JSON::ParserError => e
-
1
Core::Logger.warn "Failed to parse colors.json: #{e.message}"
-
1
{}
-
end
-
end
-
-
# Load existing defined_colors.json file
-
1
def load_defined_colors_json
-
85
return {} unless File.exist?(@defined_colors_file)
-
-
begin
-
2
JSON.parse(File.read(@defined_colors_file))
-
1
rescue JSON::ParserError => e
-
1
Core::Logger.warn "Failed to parse defined_colors.json: #{e.message}"
-
1
{}
-
end
-
end
-
-
# Save colors data to colors.json
-
1
def save_colors_json
-
# Merge extracted colors with existing colors
-
2
@colors_data.merge!(@extracted_colors)
-
-
# Ensure Resources directory exists
-
2
FileUtils.mkdir_p(@resources_dir)
-
-
# Write colors.json
-
2
File.write(@colors_file, JSON.pretty_generate(@colors_data))
-
2
Core::Logger.info "Updated colors.json with #{@extracted_colors.size} new colors"
-
-
# Clear extracted colors after saving
-
2
@extracted_colors.clear
-
end
-
-
# Apply colors to Android colors.xml file
-
1
def apply_to_colors_xml
-
9
colors_xml_path = File.join(@source_path, @config['source_directory'] || 'src/main', 'res/values/colors.xml')
-
-
9
unless File.exist?(colors_xml_path)
-
8
Core::Logger.info "colors.xml not found at: #{colors_xml_path}, creating new file"
-
-
# Ensure the directory exists
-
8
colors_dir = File.dirname(colors_xml_path)
-
8
FileUtils.mkdir_p(colors_dir)
-
-
# Create a new colors.xml with basic structure
-
8
default_xml = <<~XML
-
<?xml version="1.0" encoding="utf-8"?>
-
<resources>
-
</resources>
-
XML
-
8
File.write(colors_xml_path, default_xml)
-
end
-
-
# Load colors from colors.json
-
9
all_colors = load_colors_json
-
-
# Also include defined colors
-
9
defined_colors = load_defined_colors_json
-
9
all_colors.merge!(defined_colors) if defined_colors.any?
-
-
9
return if all_colors.empty?
-
-
# Read and parse existing colors.xml
-
2
xml_content = File.read(colors_xml_path)
-
2
doc = REXML::Document.new(xml_content)
-
2
resources = doc.root
-
-
2
unless resources
-
Core::Logger.error "Invalid colors.xml structure"
-
return
-
end
-
-
# Build a hash of existing colors for faster lookup
-
2
existing_colors = {}
-
2
resources.elements.each('color') do |elem|
-
1
name = elem.attributes['name']
-
1
existing_colors[name] = elem if name
-
end
-
-
# Add or update colors
-
2
colors_added = 0
-
2
colors_updated = 0
-
-
2
all_colors.each do |key, value|
-
# Skip if value is nil or not a hex color
-
2
next unless value && value.is_a?(String) && value.match?(/^#?[A-Fa-f0-9]{6,8}$/)
-
-
# Normalize hex color (ensure it has # and is uppercase)
-
2
hex_value = value.start_with?('#') ? value.upcase : "##{value.upcase}"
-
-
# Convert 6-digit hex to 8-digit ARGB format if needed (Android requires ARGB)
-
2
if hex_value.length == 7 # #RRGGBB
-
2
hex_value = "#FF#{hex_value[1..-1]}" # Add full opacity
-
end
-
-
2
if existing_colors[key]
-
# Update existing color if value is different
-
current_value = existing_colors[key].text
-
if current_value != hex_value
-
existing_colors[key].text = hex_value
-
colors_updated += 1
-
end
-
else
-
# Add new color element
-
2
color_elem = REXML::Element.new('color')
-
2
color_elem.add_attribute('name', key)
-
2
color_elem.text = hex_value
-
2
resources.add_element(color_elem)
-
2
colors_added += 1
-
end
-
end
-
-
2
if colors_added > 0 || colors_updated > 0
-
# Format and write back
-
2
formatter = REXML::Formatters::Pretty.new(2)
-
2
formatter.compact = true
-
2
output = String.new
-
2
formatter.write(doc, output)
-
2
File.write(colors_xml_path, output)
-
-
2
Core::Logger.info "Updated colors.xml: #{colors_added} added, #{colors_updated} updated"
-
end
-
end
-
-
# Save undefined colors to defined_colors.json
-
1
def save_defined_colors_json
-
# Merge new undefined colors with existing defined colors
-
@defined_colors_data.merge!(@undefined_colors)
-
-
# Ensure Resources directory exists
-
FileUtils.mkdir_p(@resources_dir)
-
-
# Write defined_colors.json
-
File.write(@defined_colors_file, JSON.pretty_generate(@defined_colors_data))
-
Core::Logger.info "Updated defined_colors.json with #{@undefined_colors.size} undefined color keys"
-
-
# Clear undefined colors after saving
-
@undefined_colors.clear
-
end
-
-
# Extract color values from processed JSON files
-
1
def extract_colors(processed_files)
-
10
@modified_files = []
-
-
10
Core::Logger.debug "Processing #{processed_files.size} files for colors"
-
-
10
processed_files.each do |json_file|
-
begin
-
10
Core::Logger.debug "Processing file: #{json_file}"
-
10
content = File.read(json_file)
-
10
data = JSON.parse(content)
-
-
# Extract and replace colors recursively from JSON structure
-
10
modified = replace_colors_recursive(data)
-
-
10
Core::Logger.debug "File modified: #{modified}, extracted colors: #{@extracted_colors.size}"
-
-
# Save modified JSON file if any colors were replaced
-
10
if modified
-
2
File.write(json_file, JSON.pretty_generate(data))
-
2
@modified_files << json_file
-
2
Core::Logger.debug "Updated colors in: #{json_file}"
-
end
-
rescue JSON::ParserError => e
-
Core::Logger.warn "Failed to parse #{json_file}: #{e.message}"
-
rescue => e
-
Core::Logger.error "Error processing #{json_file}: #{e.message}"
-
end
-
end
-
-
10
if @modified_files.any?
-
2
Core::Logger.info "Replaced colors in #{@modified_files.size} files"
-
end
-
end
-
-
# Replace colors recursively in JSON data
-
1
def replace_colors_recursive(data, parent_key = nil)
-
26
modified = false
-
-
26
case data
-
when Hash
-
20
data.each do |key, value|
-
# Check if this key is a color property and value is a string
-
32
if is_color_property?(key) && value.is_a?(String)
-
# Skip binding expressions (starting with @{)
-
7
if value.start_with?('@{') && value.end_with?('}')
-
2
Core::Logger.debug "Skipping binding expression: #{value}"
-
2
next
-
end
-
-
# Process and replace the color value (hex or string key)
-
5
new_value = process_and_replace_color(value)
-
5
if new_value != value
-
5
data[key] = new_value
-
5
modified = true
-
5
Core::Logger.debug "Replaced #{value} with #{new_value} in #{key}"
-
end
-
25
elsif value.is_a?(Hash) || value.is_a?(Array)
-
# Recurse into nested structures
-
6
child_modified = replace_colors_recursive(value, key)
-
6
modified ||= child_modified
-
end
-
end
-
when Array
-
6
data.each_with_index do |item, index|
-
5
if item.is_a?(Hash) || item.is_a?(Array)
-
5
child_modified = replace_colors_recursive(item, parent_key)
-
5
modified ||= child_modified
-
end
-
end
-
end
-
-
26
modified
-
end
-
-
# Check if a property name is likely to contain a color
-
1
def is_color_property?(key)
-
# Based on actual XML style_mapper.rb and Compose components
-
47
color_properties = [
-
# Common background/appearance (style_mapper.rb)
-
'background',
-
'backgroundColor',
-
'borderColor',
-
'strokeColor',
-
-
# Text colors (text_mapper.rb)
-
'fontColor',
-
'textColor',
-
'color', # Generic color that can map to textColor or tint
-
-
# State-specific backgrounds (drawable generation)
-
'disabledBackground',
-
'tapBackground',
-
'pressedBackground',
-
'selectedBackground',
-
'focusedBackground',
-
'checkedBackground',
-
'rippleColor',
-
-
# Input/SelectBox specific (input_mapper.rb, SelectBox component)
-
'hintColor',
-
'cancelButtonBackgroundColor',
-
'cancelButtonTextColor',
-
-
# Image/Icon tinting
-
'tint',
-
'tintColor',
-
-
# Gradient colors (style_mapper.rb)
-
'gradientStartColor',
-
'startColor',
-
'gradientEndColor',
-
'endColor',
-
'gradientCenterColor',
-
'centerColor',
-
-
# Blur overlay
-
'blurOverlayColor',
-
-
# Shadow
-
'shadowColor'
-
]
-
-
47
color_properties.include?(key.to_s)
-
end
-
-
# Process and replace a color value, returning the color key
-
1
def process_and_replace_color(color_value)
-
# Skip data binding expressions
-
10
return color_value if color_value.is_a?(String) && color_value.start_with?('@{')
-
-
# Handle hex colors
-
8
if is_hex_color?(color_value)
-
# Check if it's a fully transparent color (alpha = 00)
-
7
if is_transparent_color?(color_value)
-
# Add transparent to colors.json if not already present
-
unless @colors_data.key?('transparent') || @extracted_colors.key?('transparent')
-
@extracted_colors['transparent'] = '#00000000'
-
end
-
return 'transparent'
-
end
-
-
# Normalize hex color (uppercase, with #)
-
7
hex_color = normalize_hex_color(color_value)
-
-
# Check if color already exists in colors.json
-
7
existing_key = find_color_key(hex_color)
-
-
7
if existing_key
-
# Color already exists, return the key
-
1
Core::Logger.debug "Found existing color: #{existing_key} = #{hex_color}"
-
1
return existing_key
-
else
-
# Generate a new key for this color
-
6
new_key = generate_color_key(hex_color)
-
-
# Add to extracted colors
-
6
@extracted_colors[new_key] = hex_color
-
6
Core::Logger.debug "New color found: #{new_key} = #{hex_color}"
-
6
return new_key
-
end
-
# Handle string color keys
-
1
elsif color_value.is_a?(String) && !color_value.empty?
-
# Check if this color key exists in colors.json
-
1
if @colors_data.key?(color_value) || @extracted_colors.key?(color_value)
-
# Color key exists, keep it as is
-
Core::Logger.debug "Color key exists: #{color_value}"
-
return color_value
-
1
elsif @defined_colors_data.key?(color_value)
-
# Already in defined_colors, keep it as is
-
Core::Logger.debug "Color key already in defined_colors: #{color_value}"
-
return color_value
-
else
-
# Undefined color key, add to undefined colors list
-
1
@undefined_colors[color_value] = nil
-
1
Core::Logger.debug "Undefined color key found: #{color_value}"
-
1
return color_value
-
end
-
else
-
# Return as is for other types
-
return color_value
-
end
-
end
-
-
# Find existing key for a hex color
-
1
def find_color_key(hex_color)
-
# Check both existing colors and newly extracted colors
-
7
all_colors = @colors_data.merge(@extracted_colors)
-
9
all_colors.find { |key, value| value.upcase == hex_color.upcase }&.first
-
end
-
-
# Generate a descriptive key name based on RGB values
-
1
def generate_color_key(hex_color)
-
# Parse RGB values from hex
-
12
rgb = parse_hex_to_rgb(hex_color)
-
12
return 'unknown_color' unless rgb
-
-
12
r, g, b = rgb
-
-
# Calculate brightness and dominant color
-
12
brightness = (r + g + b) / 3.0
-
-
# Determine base name from brightness
-
12
base_name = if brightness > 230
-
1
'white'
-
11
elsif brightness > 200
-
'pale'
-
11
elsif brightness > 150
-
'light'
-
11
elsif brightness > 100
-
1
'medium'
-
10
elsif brightness > 50
-
7
'dark'
-
3
elsif brightness > 20
-
'deep'
-
else
-
3
'black'
-
end
-
-
# Find dominant color if not grayscale
-
12
max_diff = [r, g, b].max - [r, g, b].min
-
12
if max_diff > 30 # Not grayscale
-
# Determine dominant color
-
7
if r > g && r > b
-
7
if r - g > 50 && r - b > 50
-
7
color_suffix = '_red'
-
elsif r > b
-
color_suffix = '_orange' if g > b
-
color_suffix = '_pink' if b > g * 0.7
-
else
-
color_suffix = '_magenta'
-
end
-
elsif g > r && g > b
-
if g - r > 50 && g - b > 50
-
color_suffix = '_green'
-
elsif g > b && r > b * 0.7
-
color_suffix = '_yellow'
-
else
-
color_suffix = '_lime'
-
end
-
elsif b > r && b > g
-
if b - r > 50 && b - g > 50
-
color_suffix = '_blue'
-
elsif b > r && g > r * 0.7
-
color_suffix = '_cyan'
-
else
-
color_suffix = '_purple'
-
end
-
else
-
color_suffix = ''
-
end
-
-
7
base_name = base_name + color_suffix unless base_name == 'white' || base_name == 'black'
-
5
elsif base_name != 'white' && base_name != 'black'
-
1
base_name = base_name + '_gray'
-
end
-
-
# Handle duplicates by adding suffix
-
12
final_key = base_name
-
12
counter = 2
-
12
all_colors = @colors_data.merge(@extracted_colors)
-
-
12
while all_colors.key?(final_key)
-
1
final_key = "#{base_name}_#{counter}"
-
1
counter += 1
-
end
-
-
12
final_key
-
end
-
-
# Parse hex color to RGB values (and alpha if present)
-
1
def parse_hex_to_rgb(hex_color)
-
# Remove # if present
-
18
hex = hex_color.gsub('#', '')
-
-
# Support both 3 and 6 digit hex
-
18
if hex.length == 3
-
4
hex = hex.chars.map { |c| c * 2 }.join
-
end
-
-
# Handle 8-digit hex (ARGB) - extract RGB part
-
18
if hex.length == 8
-
# Skip alpha channel (first 2 characters) for RGB analysis
-
1
hex = hex[2..7]
-
end
-
-
18
return nil unless hex.length == 6
-
-
[
-
17
hex[0..1].to_i(16),
-
hex[2..3].to_i(16),
-
hex[4..5].to_i(16)
-
]
-
rescue
-
nil
-
end
-
-
# Check if a value is a hex color
-
1
def is_hex_color?(value)
-
18
return false unless value.is_a?(String)
-
# Support 3, 6, and 8 character hex colors (8 = ARGB with alpha)
-
16
value.match?(/^#?[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?([0-9A-Fa-f]{2})?$/)
-
end
-
-
# Check if a color is fully transparent (alpha = 00)
-
1
def is_transparent_color?(value)
-
7
return false unless value.is_a?(String)
-
7
hex = value.gsub('#', '').upcase
-
-
# Only 8-digit hex colors have alpha channel (ARGB format for Android)
-
7
return false unless hex.length == 8
-
-
# Check if alpha is 00 (fully transparent) - first 2 chars for ARGB
-
alpha = hex[0..1]
-
alpha == '00'
-
end
-
-
# Normalize hex color format
-
1
def normalize_hex_color(hex_color)
-
11
hex = hex_color.gsub('#', '').upcase
-
-
# Convert 3-digit to 6-digit
-
11
if hex.length == 3
-
4
hex = hex.chars.map { |c| c * 2 }.join
-
end
-
-
# Keep 8-digit (ARGB) as is
-
# 6-digit and 8-digit are both valid
-
-
11
"##{hex}"
-
end
-
-
# Generate Kotlin code for ColorManager
-
1
def generate_color_manager_kotlin
-
1
return unless @config['resource_manager_directory']
-
-
1
resource_manager_dir = File.join(@source_path, @config['resource_manager_directory'])
-
1
FileUtils.mkdir_p(resource_manager_dir)
-
-
1
output_file = File.join(resource_manager_dir, 'ColorManager.kt')
-
-
# Combine all colors (from colors.json and defined_colors.json)
-
1
all_colors = @colors_data.dup
-
-
# Add defined colors (keys without values yet)
-
1
@defined_colors_data.each do |key, _|
-
all_colors[key] ||= nil
-
end
-
-
1
kotlin_code = generate_kotlin_code(all_colors)
-
-
1
File.write(output_file, kotlin_code)
-
1
Core::Logger.info "✓ Generated ColorManager.kt"
-
end
-
-
1
def generate_kotlin_code(colors)
-
12
timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
-
-
12
code = []
-
12
code << "// ColorManager.kt"
-
12
code << "// Auto-generated file - DO NOT EDIT"
-
12
code << "// Generated at: #{timestamp}"
-
12
code << ""
-
12
code << "package com.kotlinjsonui.generated"
-
12
code << ""
-
12
code << "import android.graphics.Color"
-
12
code << "import android.util.Log"
-
12
code << "import androidx.compose.ui.graphics.Color as ComposeColor"
-
12
code << ""
-
12
code << "object ColorManager {"
-
12
code << " private const val TAG = \"ColorManager\""
-
12
code << " "
-
12
code << " // Load colors from colors.json"
-
12
if @colors_data.empty?
-
11
code << " private val colorsData: Map<String, String> = emptyMap()"
-
else
-
1
code << " private val colorsData: Map<String, String> = mapOf("
-
-
# Add defined colors from colors.json
-
1
@colors_data.each_with_index do |(key, hex_value), index|
-
2
comma = index < @colors_data.size - 1 ? "," : ""
-
2
code << " \"#{key}\" to \"#{hex_value}\"#{comma}"
-
end
-
-
1
code << " )"
-
end
-
12
code << ""
-
12
code << " // Android Views colors"
-
12
code << " object views {"
-
12
code << " // Get Color by key (returns null for binding expressions like @{...})"
-
12
code << " fun color(key: String): Int? {"
-
12
code << " // Skip binding expressions"
-
12
code << " if (key.startsWith(\"@{\") && key.endsWith(\"}\")) {"
-
12
code << " return null"
-
12
code << " }"
-
12
code << " val hexString = colorsData[key]"
-
12
code << " if (hexString == null) {"
-
12
code << " Log.w(TAG, \"Color key '$key' not found in colors.json\")"
-
12
code << " return try {"
-
12
code << " Color.parseColor(key) // Try to parse key as hex color"
-
12
code << " } catch (e: IllegalArgumentException) {"
-
12
code << " null"
-
12
code << " }"
-
12
code << " }"
-
12
code << " return try {"
-
12
code << " Color.parseColor(hexString)"
-
12
code << " } catch (e: IllegalArgumentException) {"
-
12
code << " Log.w(TAG, \"Invalid color format '$hexString' for key '$key'\")"
-
12
code << " null"
-
12
code << " }"
-
12
code << " }"
-
12
code << ""
-
-
# Generate static color accessors for Android Views
-
12
colors.keys.sort.each do |key|
-
12
property_name = snake_to_camel(key)
-
-
12
code << " val #{property_name}: Int?"
-
12
code << " get() {"
-
-
12
if @colors_data[key]
-
2
code << " return try {"
-
2
code << " Color.parseColor(\"#{@colors_data[key]}\")"
-
2
code << " } catch (e: IllegalArgumentException) {"
-
2
code << " Log.w(TAG, \"Invalid color format '#{@colors_data[key]}' for '#{key}'\")"
-
2
code << " null"
-
2
code << " }"
-
else
-
10
code << " // Undefined color - needs to be defined in colors.json"
-
10
code << " Log.w(TAG, \"Color '#{key}' is not defined in colors.json\")"
-
10
code << " return null"
-
end
-
-
12
code << " }"
-
12
code << ""
-
end
-
-
12
code << " }"
-
12
code << ""
-
12
code << " // Jetpack Compose colors"
-
12
code << " object compose {"
-
12
code << " // Get Compose Color by key (returns null for binding expressions like @{...})"
-
12
code << " fun color(key: String): ComposeColor? {"
-
12
code << " // Skip binding expressions"
-
12
code << " if (key.startsWith(\"@{\") && key.endsWith(\"}\")) {"
-
12
code << " return null"
-
12
code << " }"
-
12
code << " val androidColor = views.color(key) ?: return null"
-
12
code << " return ComposeColor(androidColor)"
-
12
code << " }"
-
12
code << ""
-
-
# Generate static Compose Color accessors
-
12
colors.keys.sort.each do |key|
-
12
property_name = snake_to_camel(key)
-
-
12
code << " val #{property_name}: ComposeColor?"
-
12
code << " get() {"
-
12
code << " val androidColor = views.#{property_name} ?: return null"
-
12
code << " return ComposeColor(androidColor)"
-
12
code << " }"
-
12
code << ""
-
end
-
-
12
code << " }"
-
12
code << "}"
-
12
code << ""
-
12
code << "// Note: Color parsing extensions are provided by KotlinJsonUI library"
-
-
12
code.join("\n")
-
end
-
-
1
def snake_to_camel(snake_case)
-
# Convert snake_case to camelCase
-
# Examples:
-
# primary_blue -> primaryBlue
-
# white_2 -> white2
-
# dark_gray -> darkGray
-
28
parts = snake_case.split('_')
-
28
first_part = parts.shift
-
28
camel = first_part + parts.map(&:capitalize).join
-
28
camel
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'json'
-
1
require 'fileutils'
-
1
require 'rexml/document'
-
1
require_relative '../logger'
-
-
1
module KjuiTools
-
1
module Core
-
1
module Resources
-
1
class StringManager
-
1
def initialize(config, source_path, resources_dir)
-
48
@config = config
-
48
@source_path = source_path
-
48
@resources_dir = resources_dir
-
48
@strings_file = File.join(@resources_dir, 'strings.json')
-
48
@extracted_strings = {} # Structure: { "filename": { "key": "value" } }
-
48
@strings_data = load_strings_json
-
end
-
-
# Main process method called from ResourcesManager
-
1
def process_strings(processed_files, processed_count, skipped_count)
-
9
return if processed_files.empty?
-
-
8
Core::Logger.info "Extracting strings from #{processed_count} files (#{skipped_count} skipped)..."
-
-
# Extract strings from JSON files
-
8
extract_strings(processed_files)
-
-
# Save updated strings.json if there are new strings
-
8
save_strings_json if @extracted_strings.any?
-
-
# Generate StringManager.kt if needed
-
# Disabled: StringManager.kt generation is not needed
-
# generate_string_manager_kotlin if @config['resource_manager_directory']
-
end
-
-
# Apply extracted strings to strings.xml files
-
1
def apply_to_strings_files
-
9
return if @strings_data.empty?
-
-
# Get string files from config
-
5
string_files = @config['string_files'] || []
-
-
5
if string_files.empty?
-
# Default: update strings.xml for default language
-
5
update_strings_xml('values')
-
else
-
# Update configured string files
-
string_files.each do |string_file_path|
-
# Extract values directory from path (e.g., "res/values-ja/strings.xml" -> "values-ja")
-
if string_file_path =~ /res\/(values[^\/]*)\//
-
lang_dir = $1
-
update_strings_xml(lang_dir)
-
elsif string_file_path =~ /(values[^\/]*)\//
-
lang_dir = $1
-
update_strings_xml(lang_dir)
-
else
-
# If no standard pattern, try to use the parent directory name
-
parts = string_file_path.split('/')
-
if parts.length >= 2
-
lang_dir = parts[-2]
-
update_strings_xml(lang_dir) if lang_dir.start_with?('values')
-
end
-
end
-
end
-
end
-
end
-
-
1
private
-
-
# Load existing strings.json file
-
1
def load_strings_json
-
48
return {} unless File.exist?(@strings_file)
-
-
begin
-
JSON.parse(File.read(@strings_file))
-
rescue JSON::ParserError => e
-
Core::Logger.warn "Failed to parse strings.json: #{e.message}"
-
{}
-
end
-
end
-
-
# Save strings data to strings.json
-
1
def save_strings_json
-
# Count total new strings
-
5
total_new_strings = 0
-
5
@extracted_strings.each do |file_prefix, file_strings|
-
5
total_new_strings += file_strings.size
-
end
-
-
# Merge extracted strings with existing strings
-
5
@extracted_strings.each do |file_prefix, file_strings|
-
5
@strings_data[file_prefix] ||= {}
-
5
@strings_data[file_prefix].merge!(file_strings)
-
end
-
-
# Ensure Resources directory exists
-
5
FileUtils.mkdir_p(@resources_dir)
-
-
# Write strings.json
-
5
File.write(@strings_file, JSON.pretty_generate(@strings_data))
-
5
Core::Logger.info "Updated strings.json with #{total_new_strings} new strings"
-
-
# Clear extracted strings after saving
-
5
@extracted_strings.clear
-
end
-
-
# Extract string values from processed JSON files
-
1
def extract_strings(processed_files)
-
8
@modified_files = []
-
-
8
Core::Logger.debug "Processing #{processed_files.size} files for strings"
-
-
# Get the layouts directory to calculate relative paths
-
8
layouts_dir = File.join(@source_path, @config['source_directory'] || 'src/main', 'assets/Layouts')
-
-
8
processed_files.each do |json_file|
-
begin
-
8
Core::Logger.debug "Processing file: #{json_file}"
-
8
content = File.read(json_file)
-
8
data = JSON.parse(content)
-
-
# Get file prefix from relative path
-
8
relative_path = Pathname.new(json_file).relative_path_from(Pathname.new(layouts_dir)).to_s
-
8
file_prefix = generate_file_prefix(relative_path)
-
-
# Create current file strings container if not exists
-
8
@current_file_strings = {}
-
-
# Extract strings recursively from JSON structure (without modifying)
-
8
extract_strings_recursive(data, nil, file_prefix)
-
-
# Store extracted strings for this file if any
-
8
if @current_file_strings.any?
-
5
@extracted_strings[file_prefix] ||= {}
-
5
@extracted_strings[file_prefix].merge!(@current_file_strings)
-
5
Core::Logger.debug "Extracted #{@current_file_strings.size} strings from #{file_prefix}"
-
end
-
-
# NOTE: We don't modify the original JSON files anymore
-
# The resource resolution happens during code generation
-
rescue JSON::ParserError => e
-
Core::Logger.warn "Failed to parse #{json_file}: #{e.message}"
-
rescue => e
-
Core::Logger.error "Error processing #{json_file}: #{e.message}"
-
end
-
end
-
-
8
if @modified_files.any?
-
Core::Logger.info "Replaced strings in #{@modified_files.size} files"
-
end
-
end
-
-
# Generate file prefix from relative path
-
1
def generate_file_prefix(relative_path)
-
# Remove .json extension and replace / with _
-
# Examples:
-
# "test.json" -> "test"
-
# "subdir/test.json" -> "subdir_test"
-
# "a/b/c/test.json" -> "a_b_c_test"
-
11
relative_path
-
.gsub(/\.json$/, '')
-
.gsub('/', '_')
-
end
-
-
# Extract strings recursively from JSON data (without modifying)
-
1
def extract_strings_recursive(data, parent_key = nil, file_prefix = nil)
-
17
case data
-
when Hash
-
12
data.each do |key, value|
-
# Special handling for partialAttributes
-
25
if key == 'partialAttributes' && value.is_a?(Array)
-
value.each do |partial_attr|
-
if partial_attr.is_a?(Hash) && partial_attr['range'].is_a?(String)
-
# Process range text when it's a string (not an array)
-
range_text = partial_attr['range']
-
if !range_text.empty? && should_extract_string?(range_text)
-
extract_and_store_string(range_text, file_prefix)
-
end
-
end
-
end
-
# Regular string property handling
-
25
elsif is_string_property?(key) && value.is_a?(String) && !value.empty?
-
# Extract the string value
-
5
if should_extract_string?(value)
-
5
extract_and_store_string(value, file_prefix)
-
end
-
20
elsif value.is_a?(Hash) || value.is_a?(Array)
-
# Recurse into nested structures
-
5
extract_strings_recursive(value, key, file_prefix)
-
end
-
end
-
when Array
-
5
data.each_with_index do |item, index|
-
4
if item.is_a?(Hash) || item.is_a?(Array)
-
4
extract_strings_recursive(item, parent_key, file_prefix)
-
end
-
end
-
end
-
end
-
-
# Check if a property name is likely to contain a localizable string
-
1
def is_string_property?(key)
-
# Based on actual XML mapper and Compose components code
-
32
string_properties = [
-
'text', # Text, Button, TextField, TextView, Checkbox
-
'hint', # TextField, SelectBox (both XML and Compose)
-
'placeholder', # TextField, SelectBox alternative to hint
-
'label', # Checkbox label
-
'prompt' # SelectBox (maps to placeholder in XML)
-
]
-
-
32
string_properties.include?(key.to_s)
-
end
-
-
# Check if a string should be extracted for localization
-
1
def should_extract_string?(value)
-
# Skip data binding expressions
-
12
return false if value.start_with?('@{') || value.start_with?('${')
-
-
# Skip if it's already a snake_case key (already converted)
-
# This prevents re-extracting strings that have been replaced with keys
-
10
return false if value.match?(/^[a-z]+(_[a-z0-9]+)*$/)
-
-
# Extract if it's a regular text string longer than 2 characters
-
# and contains alphabetic characters
-
8
value.length > 2 && value.match?(/[a-zA-Z]/)
-
end
-
-
# Extract and store string (without returning a key)
-
1
def extract_and_store_string(value, file_prefix = nil)
-
# Generate a snake_case key from the text
-
5
key = generate_string_key(value)
-
-
# Check if this exact string already has a key in this file
-
5
existing_key = find_string_key_in_file(value, file_prefix)
-
5
if existing_key
-
Core::Logger.debug "String already extracted: #{existing_key}"
-
return
-
end
-
-
# Add to current file strings
-
5
@current_file_strings[key] = value
-
5
Core::Logger.debug "New string extracted: #{key} = '#{value}'"
-
end
-
-
# Find existing key for a string value in a specific file
-
1
def find_string_key_in_file(value, file_prefix)
-
5
return nil unless file_prefix
-
-
# Check if this file has been processed before
-
5
if @strings_data[file_prefix]
-
# Look for existing key in this file's strings
-
@strings_data[file_prefix].find { |key, val| val == value }&.first
-
end
-
-
# Also check current file's strings being extracted
-
5
if @current_file_strings
-
5
found_key = @current_file_strings.find { |key, val| val == value }&.first
-
5
return "#{file_prefix}_#{found_key}" if found_key
-
end
-
-
nil
-
end
-
-
# Find existing key for a string value (legacy method)
-
1
def find_string_key(value)
-
# Check both existing strings and newly extracted strings
-
all_strings = @strings_data.merge(@extracted_strings)
-
all_strings.find { |key, val| val == value }&.first
-
end
-
-
# Generate a snake_case key from text
-
1
def generate_string_key(text)
-
# Convert to snake_case
-
10
base_key = text
-
.downcase
-
.gsub(/[^a-z0-9\s]/, '') # Remove special characters
-
.gsub(/\s+/, '_') # Replace spaces with underscores
-
.gsub(/^_+|_+$/, '') # Remove leading/trailing underscores
-
.gsub(/__+/, '_') # Replace multiple underscores with single
-
-
# Limit length
-
10
base_key = base_key[0..30] if base_key.length > 30
-
-
# Handle duplicates
-
10
final_key = base_key
-
10
counter = 2
-
10
all_strings = @strings_data.merge(@extracted_strings)
-
-
10
while all_strings.key?(final_key)
-
final_key = "#{base_key}_#{counter}"
-
counter += 1
-
end
-
-
10
final_key
-
end
-
-
# Update strings.xml file for a specific language
-
1
def update_strings_xml(lang_dir)
-
4
Core::Logger.debug "Updating strings.xml for #{lang_dir}..."
-
4
res_dir = File.join(@source_path, @config['source_directory'] || 'src/main', 'res', lang_dir)
-
4
FileUtils.mkdir_p(res_dir)
-
-
4
strings_xml_file = File.join(res_dir, 'strings.xml')
-
4
Core::Logger.debug "Strings.xml path: #{strings_xml_file}"
-
-
# Load existing strings.xml or create new
-
4
doc = if File.exist?(strings_xml_file)
-
Core::Logger.debug "Loading existing strings.xml..."
-
REXML::Document.new(File.read(strings_xml_file))
-
else
-
4
Core::Logger.debug "Creating new strings.xml..."
-
4
create_new_strings_xml
-
end
-
-
4
resources = doc.root
-
4
Core::Logger.debug "Processing #{@strings_data.keys.length} files..."
-
-
# Build a hash of existing strings for faster lookup
-
4
existing_strings = {}
-
4
resources.elements.each('string') do |elem|
-
name = elem.attributes['name']
-
existing_strings[name] = elem if name
-
end
-
4
Core::Logger.debug "Found #{existing_strings.keys.length} existing strings"
-
-
# Add new strings from strings.json (now structured by file)
-
4
@strings_data.each do |file_prefix, file_strings|
-
4
next unless file_strings.is_a?(Hash)
-
4
Core::Logger.debug "Processing #{file_prefix} with #{file_strings.keys.length} strings..."
-
4
file_strings.each do |key, value|
-
# Create full key with file prefix
-
4
full_key = "#{file_prefix}_#{key}"
-
-
# Check if string already exists (using hash lookup - much faster)
-
4
unless existing_strings[full_key]
-
# Add new string element
-
4
string_elem = REXML::Element.new('string')
-
4
string_elem.add_attribute('name', full_key)
-
-
# Use translated value if available for this language
-
4
translated_value = get_translated_value(full_key, value, lang_dir)
-
# Trim whitespace and normalize the string for XML
-
4
normalized_value = translated_value.strip.gsub(/\s+/, ' ')
-
# Escape apostrophes for Android XML strings
-
4
normalized_value = normalized_value.gsub("'", "\\'")
-
# Don't let REXML auto-escape, we'll do it manually
-
4
string_elem.text = normalized_value
-
-
4
resources.add_element(string_elem)
-
4
Core::Logger.debug "Added string '#{full_key}' to #{lang_dir}/strings.xml"
-
end
-
end
-
end
-
-
# Write updated XML with custom formatting to prevent multiline strings
-
4
File.open(strings_xml_file, 'w') do |file|
-
# Use a custom formatter that doesn't wrap text content
-
4
formatter = REXML::Formatters::Pretty.new(4)
-
4
formatter.compact = true # Don't add extra whitespace inside text
-
4
formatter.write(doc, file)
-
end
-
-
4
Core::Logger.info "Updated #{lang_dir}/strings.xml"
-
end
-
-
# Create a new strings.xml document
-
1
def create_new_strings_xml
-
6
doc = REXML::Document.new
-
6
doc.add(REXML::XMLDecl.new('1.0', 'utf-8'))
-
-
6
resources = REXML::Element.new('resources')
-
6
doc.add_element(resources)
-
-
6
doc
-
end
-
-
# Get translated value for a specific language
-
1
def get_translated_value(key, default_value, lang_dir)
-
# For now, return the default value
-
# In the future, this could load translations from a separate file
-
5
default_value
-
end
-
-
# Generate Kotlin code for StringManager
-
1
def generate_string_manager_kotlin
-
return unless @config['resource_manager_directory']
-
-
resource_manager_dir = File.join(@source_path, @config['source_directory'] || 'src/main',
-
'java/com/kotlinjsonui/generated')
-
FileUtils.mkdir_p(resource_manager_dir)
-
-
output_file = File.join(resource_manager_dir, 'StringManager.kt')
-
-
kotlin_code = generate_kotlin_code(@strings_data)
-
-
File.write(output_file, kotlin_code)
-
Core::Logger.info "✓ Generated StringManager.kt"
-
end
-
-
1
def generate_kotlin_code(strings)
-
timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
-
-
code = []
-
code << "// StringManager.kt"
-
code << "// Auto-generated file - DO NOT EDIT"
-
code << "// Generated at: #{timestamp}"
-
code << ""
-
code << "package com.kotlinjsonui.generated"
-
code << ""
-
code << "import android.content.Context"
-
code << ""
-
code << "object StringManager {"
-
code << " // String resource IDs mapped from strings.json keys"
-
code << " private val stringResources: Map<String, Int> = mapOf("
-
-
# Add string resource mappings
-
strings.keys.sort.each do |key|
-
code << " \"#{key}\" to R.string.#{key},"
-
end
-
-
# Remove trailing comma from last item
-
if strings.any?
-
code[-1] = code[-1].chomp(',')
-
end
-
-
code << " )"
-
code << ""
-
code << " // Get localized string by key"
-
code << " fun getString(context: Context, key: String): String {"
-
code << " val resId = stringResources[key]"
-
code << " return if (resId != null) {"
-
code << " context.getString(resId)"
-
code << " } else {"
-
code << " // Fallback to key itself if not found"
-
code << " println(\"Warning: String key '$key' not found in strings.json\")"
-
code << " key"
-
code << " }"
-
code << " }"
-
code << ""
-
code << " // Extension function for easy access"
-
code << " fun String.localized(context: Context): String {"
-
code << " // Check if this is a string key (snake_case)"
-
code << " return if (this.matches(Regex(\"^[a-z]+(_[a-z]+)*$\"))) {"
-
code << " getString(context, this)"
-
code << " } else {"
-
code << " // Return as-is if not a key"
-
code << " this"
-
code << " }"
-
code << " }"
-
code << ""
-
-
# Generate static accessors for each string
-
strings.keys.sort.each do |key|
-
property_name = snake_to_camel(key)
-
-
code << " // Access string: #{key}"
-
code << " fun get#{property_name.capitalize}(context: Context): String ="
-
code << " getString(context, \"#{key}\")"
-
code << ""
-
end
-
-
code << "}"
-
-
code.join("\n")
-
end
-
-
1
def snake_to_camel(snake_case)
-
3
parts = snake_case.split('_')
-
3
first_part = parts.shift
-
3
camel = first_part + parts.map(&:capitalize).join
-
3
camel
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'json'
-
1
require 'fileutils'
-
1
require_relative 'config_manager'
-
1
require_relative 'project_finder'
-
1
require_relative 'logger'
-
1
require_relative 'resources/string_manager'
-
1
require_relative 'resources/color_manager'
-
-
1
module KjuiTools
-
1
module Core
-
1
class ResourcesManager
-
1
def initialize(config, source_path)
-
17
@config = config
-
17
@source_path = source_path
-
17
@layouts_dir = File.join(@source_path, @config['source_directory'] || 'src/main', 'assets/Layouts')
-
17
@resources_dir = File.join(@layouts_dir, 'Resources')
-
17
@string_manager = Resources::StringManager.new(@config, @source_path, @resources_dir)
-
17
@color_manager = Resources::ColorManager.new(@config, @source_path, @resources_dir)
-
end
-
-
# Main method called from build command
-
1
def extract_resources(json_files)
-
# Extract resources from JSON files
-
10
extract_from_json_files(json_files)
-
-
# Apply extracted strings to strings.xml files
-
10
apply_extracted_strings
-
-
# Apply extracted colors
-
10
apply_extracted_colors
-
end
-
-
# Extract resources from JSON files
-
1
def extract_from_json_files(json_files)
-
13
processed_files = []
-
13
processed_count = 0
-
13
skipped_count = 0
-
-
13
json_files.each do |json_file|
-
# Skip files in Resources directory only
-
11
if json_file.include?('/Resources/')
-
2
skipped_count += 1
-
2
next
-
end
-
-
9
processed_files << json_file
-
9
processed_count += 1
-
end
-
-
13
if processed_count == 0
-
4
Logger.info "No files to process for resource extraction"
-
4
return
-
end
-
-
9
Logger.info "Extracting resources from #{processed_count} files (#{skipped_count} skipped)..."
-
-
# Ensure Resources directory exists
-
9
FileUtils.mkdir_p(@resources_dir)
-
-
# Process strings through StringManager
-
9
@string_manager.process_strings(processed_files, processed_count, skipped_count)
-
-
# Process colors through ColorManager
-
9
@color_manager.process_colors(processed_files, processed_count, skipped_count, @config)
-
end
-
-
1
private
-
-
1
def apply_extracted_strings
-
10
Logger.info "Applying extracted strings to strings.xml files..."
-
10
@string_manager.apply_to_strings_files
-
end
-
-
1
def apply_extracted_colors
-
10
Logger.info "Applying extracted colors..."
-
10
@color_manager.apply_to_color_assets
-
end
-
end
-
end
-
end
-
#!/usr/bin/env ruby
-
-
1
require 'json'
-
-
1
class StyleLoader
-
1
def initialize(config)
-
39
@config = config
-
39
@styles = {}
-
39
load_styles
-
end
-
-
1
def apply_styles(json_data)
-
13
apply_styles_recursive(json_data)
-
13
json_data
-
end
-
-
1
private
-
-
1
def load_styles
-
39
project_path = @config['project_path'] || Dir.pwd
-
39
styles_dir = File.join(project_path, 'src', 'main', 'assets', 'Styles')
-
39
styles_dir = File.join(project_path, 'app', 'src', 'main', 'assets', 'Styles') unless Dir.exist?(styles_dir)
-
39
return unless Dir.exist?(styles_dir)
-
-
14
Dir.glob(File.join(styles_dir, '*.json')).each do |style_file|
-
25
style_name = File.basename(style_file, '.json')
-
begin
-
25
style_content = File.read(style_file)
-
25
@styles[style_name] = JSON.parse(style_content)
-
rescue => e
-
1
puts "Warning: Failed to load style #{style_name}: #{e.message}"
-
end
-
end
-
end
-
-
1
def apply_styles_recursive(element)
-
18
return unless element.is_a?(Hash)
-
-
# Apply style if present
-
17
if element['style']
-
8
style_names = element['style'].is_a?(Array) ? element['style'] : [element['style']]
-
-
8
style_names.each do |style_name|
-
9
if @styles[style_name]
-
# Merge style attributes (style attributes are overridden by inline attributes)
-
8
@styles[style_name].each do |key, value|
-
15
element[key] = value unless element.key?(key)
-
end
-
end
-
end
-
-
# Remove style attribute after applying
-
8
element.delete('style')
-
end
-
-
# Apply recursively to children
-
17
if element['children']
-
2
element['children'].each { |child| apply_styles_recursive(child) }
-
16
elsif element['child']
-
5
if element['child'].is_a?(Array)
-
7
element['child'].each { |child| apply_styles_recursive(child) }
-
else
-
1
apply_styles_recursive(element['child'])
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module KjuiTools
-
1
module Core
-
# Converts JSON primitive types to Kotlin types
-
# This ensures cross-platform compatibility with SwiftJsonUI and ReactJsonUI
-
1
class TypeConverter
-
# Language key for this platform
-
1
LANGUAGE = 'kotlin'
-
-
# Available modes for this platform
-
1
MODES = %w[compose xml].freeze
-
-
# JSON type -> Kotlin type mapping (common types)
-
1
TYPE_MAPPING = {
-
# Standard types (cross-platform)
-
'String' => 'String',
-
'string' => 'String',
-
'Int' => 'Int',
-
'int' => 'Int',
-
'Integer' => 'Int',
-
'integer' => 'Int',
-
'Double' => 'Double',
-
'double' => 'Double',
-
'Float' => 'Float',
-
'float' => 'Float',
-
'Bool' => 'Boolean',
-
'bool' => 'Boolean',
-
'Boolean' => 'Boolean',
-
'boolean' => 'Boolean',
-
# iOS-specific types mapped to Kotlin equivalents
-
'CGFloat' => 'Float',
-
'Void' => 'Unit',
-
'void' => 'Unit',
-
# Kotlin/Compose-specific types
-
'Dp' => 'Dp',
-
'Alignment' => 'Alignment',
-
# Collection types
-
'CollectionDataSource' => 'CollectionDataSource'
-
}.freeze
-
-
# Mode-specific type mapping (types that differ between compose and xml)
-
MODE_TYPE_MAPPING = {
-
1
'Color' => { 'compose' => 'Color', 'xml' => 'Int' },
-
'color' => { 'compose' => 'Color', 'xml' => 'Int' },
-
'Image' => { 'compose' => 'Painter', 'xml' => 'Drawable' },
-
'image' => { 'compose' => 'Painter', 'xml' => 'Drawable' }
-
}.freeze
-
-
# Default values for each Kotlin type
-
1
DEFAULT_VALUES = {
-
'String' => '""',
-
'Int' => '0',
-
'Double' => '0.0',
-
'Float' => '0f',
-
'Boolean' => 'false',
-
'Color' => 'Color.Unspecified',
-
'Dp' => '0.dp',
-
'Alignment' => 'Alignment.TopStart',
-
'Painter' => 'EmptyPainter()',
-
'Drawable' => 'null',
-
'CollectionDataSource' => 'CollectionDataSource()'
-
}.freeze
-
-
1
class << self
-
# Extract platform-specific value from a potentially nested hash
-
# Supports three formats:
-
# 1. Simple value: "String" -> "String"
-
# 2. Language only: { "swift": "Int", "kotlin": "Int" } -> "Int"
-
# 3. Language + mode: { "kotlin": { "compose": "Color", "xml": "Int" } } -> "Color" or "Int"
-
#
-
# @param value [Object] the value (String, Hash, or other)
-
# @param mode [String] the mode (compose, xml)
-
# @return [Object] the extracted value for this platform/mode
-
1
def extract_platform_value(value, mode = nil)
-
33
return value unless value.is_a?(Hash)
-
-
# Try to get language-specific value
-
9
lang_value = value[LANGUAGE]
-
9
return value unless lang_value # No language key found, return original hash
-
-
# If language value is a hash, try to get mode-specific value
-
8
if lang_value.is_a?(Hash) && mode
-
7
mode_value = lang_value[mode]
-
7
return mode_value if mode_value
-
-
# Fallback: try first available mode
-
1
MODES.each do |m|
-
1
return lang_value[m] if lang_value[m]
-
end
-
-
# No mode found, return the hash as-is (might be a custom structure)
-
lang_value
-
else
-
# Language value is not a hash, return it directly
-
1
lang_value
-
end
-
end
-
-
# Convert JSON type to Kotlin type
-
# @param json_type [String] the type specified in JSON
-
# @param mode [String] the mode (compose, xml) for mode-specific types
-
# @return [String] the corresponding Kotlin type
-
1
def to_kotlin_type(json_type, mode = nil)
-
77
return json_type if json_type.nil? || json_type.to_s.empty?
-
-
75
type_str = json_type.to_s
-
-
# Check for Array(ElementType) syntax -> List<ElementType>
-
75
if (match = type_str.match(/^Array\((.+)\)$/))
-
4
element_type = to_kotlin_type(match[1].strip, mode)
-
4
return "List<#{element_type}>"
-
end
-
-
# Check for Dictionary(KeyType,ValueType) syntax -> Map<KeyType, ValueType>
-
71
if (match = type_str.match(/^Dictionary\((.+),\s*(.+)\)$/))
-
3
key_type = to_kotlin_type(match[1].strip, mode)
-
3
value_type = to_kotlin_type(match[2].strip, mode)
-
3
return "Map<#{key_type}, #{value_type}>"
-
end
-
-
# Check for Swift optional callback syntax (() -> Void)? -> (() -> Unit)?
-
68
if (match = type_str.match(/^\(\((.*)?\)\s*->\s*Void\)\?$/))
-
2
params = match[1]&.strip
-
2
if params.nil? || params.empty?
-
1
return "(() -> Unit)?"
-
else
-
# Convert parameter types
-
2
param_types = params.split(',').map { |p| to_kotlin_type(p.strip, mode) }.join(', ')
-
1
return "((#{param_types}) -> Unit)?"
-
end
-
end
-
-
# Check for Swift callback syntax (() -> Void) -> () -> Unit (with outer parens)
-
66
if (match = type_str.match(/^\(\((.*)?\)\s*->\s*Void\)$/))
-
5
params = match[1]&.strip
-
5
if params.nil? || params.empty?
-
1
return "() -> Unit"
-
else
-
# Convert parameter types
-
9
param_types = params.split(',').map { |p| to_kotlin_type(p.strip, mode) }.join(', ')
-
4
return "(#{param_types}) -> Unit"
-
end
-
end
-
-
# Check for Swift simple callback syntax () -> Void -> (() -> Unit)? (without outer parens, treated as optional)
-
61
if (match = type_str.match(/^\((.*)?\)\s*->\s*Void$/))
-
2
params = match[1]&.strip
-
2
if params.nil? || params.empty?
-
1
return "(() -> Unit)?"
-
else
-
# Convert parameter types
-
2
param_types = params.split(',').map { |p| to_kotlin_type(p.strip, mode) }.join(', ')
-
1
return "((#{param_types}) -> Unit)?"
-
end
-
end
-
-
# Check mode-specific mapping first
-
59
if mode && MODE_TYPE_MAPPING.key?(type_str)
-
8
return MODE_TYPE_MAPPING[type_str][mode] || MODE_TYPE_MAPPING[type_str]['compose']
-
end
-
-
# Then check common mapping, or return as-is if not found
-
51
TYPE_MAPPING[type_str] || type_str
-
end
-
-
# Check if the type is a primitive type
-
# @param json_type [String] the type to check
-
# @return [Boolean] true if it's a primitive type
-
1
def primitive?(json_type)
-
9
return false if json_type.nil? || json_type.to_s.empty?
-
-
7
TYPE_MAPPING.key?(json_type.to_s)
-
end
-
-
# Get default value for a Kotlin type
-
# @param kotlin_type [String] the Kotlin type
-
# @return [String] the default value as Kotlin code
-
1
def default_value(kotlin_type)
-
9
DEFAULT_VALUES[kotlin_type] || 'null'
-
end
-
-
# Format a value for Kotlin code based on type
-
# @param value [Object] the value to format
-
# @param kotlin_type [String] the Kotlin type
-
# @return [String] the formatted value as Kotlin code
-
1
def format_value(value, kotlin_type)
-
return 'null' if value.nil?
-
-
case kotlin_type
-
when 'String'
-
format_string_value(value)
-
when 'Int'
-
value.to_i.to_s
-
when 'Double'
-
"#{value.to_f}"
-
when 'Float'
-
"#{value.to_f}f"
-
when 'Boolean'
-
value.to_s.downcase
-
when 'Color'
-
format_color_value(value)
-
else
-
value.to_s
-
end
-
end
-
-
# Convert data property from JSON format to normalized format
-
# @param data_prop [Hash] the data property from JSON
-
# @param mode [String] the mode (compose, xml)
-
# @return [Hash] normalized data property with Kotlin type
-
1
def normalize_data_property(data_prop, mode = nil)
-
15
return data_prop unless data_prop.is_a?(Hash)
-
-
15
normalized = data_prop.dup
-
-
# Extract platform-specific class
-
15
if normalized['class']
-
15
raw_class = extract_platform_value(normalized['class'], mode)
-
15
normalized['class'] = to_kotlin_type(raw_class, mode)
-
end
-
-
# Extract platform-specific defaultValue
-
15
if normalized['defaultValue']
-
11
normalized['defaultValue'] = extract_platform_value(normalized['defaultValue'], mode)
-
end
-
-
15
normalized
-
end
-
-
# Convert array of data properties
-
# @param data_props [Array<Hash>] array of data properties
-
# @param mode [String] the mode (compose, xml)
-
# @return [Array<Hash>] normalized data properties
-
1
def normalize_data_properties(data_props, mode = nil)
-
3
return [] unless data_props.is_a?(Array)
-
-
4
data_props.map { |prop| normalize_data_property(prop, mode) }
-
end
-
-
1
private
-
-
1
def format_string_value(value)
-
str = value.to_s
-
# Handle already quoted strings
-
if str.start_with?('"') && str.end_with?('"')
-
str
-
elsif str.start_with?("'") && str.end_with?("'")
-
# Convert single quotes to double quotes
-
inner = str[1..-2]
-
"\"#{escape_string(inner)}\""
-
else
-
"\"#{escape_string(str)}\""
-
end
-
end
-
-
1
def escape_string(str)
-
str.gsub('\\', '\\\\').gsub('"', '\\"')
-
end
-
-
1
def format_color_value(value)
-
if value.is_a?(String) && value.start_with?('#')
-
hex = value.sub('#', '')
-
if hex.length == 6
-
"Color(0xFF#{hex.upcase})"
-
elsif hex.length == 8
-
"Color(0x#{hex.upcase})"
-
else
-
"Color.Unspecified"
-
end
-
else
-
value.to_s
-
end
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'socket'
-
1
require 'json'
-
1
require 'fileutils'
-
-
1
module KjuiTools
-
1
module Hotloader
-
1
class IpMonitor
-
1
CONFIG_FILE = 'kjui.config.json'
-
1
CHECK_INTERVAL = 5 # seconds
-
-
1
def initialize(project_root = nil)
-
14
@project_root = project_root || find_project_root
-
14
@config_path = File.join(@project_root, CONFIG_FILE)
-
14
@running = false
-
14
@thread = nil
-
14
@last_ip = nil
-
end
-
-
1
def start
-
4
return if @running
-
-
3
@running = true
-
3
@thread = Thread.new do
-
3
while @running
-
check_and_update_ip
-
sleep CHECK_INTERVAL
-
end
-
end
-
-
3
puts "IP Monitor started"
-
end
-
-
1
def stop
-
3
@running = false
-
3
@thread&.join
-
3
puts "IP Monitor stopped"
-
end
-
-
1
private
-
-
1
def find_project_root(start_path = Dir.pwd)
-
3
current = start_path
-
-
# First check current and parent directories
-
3
while current != '/'
-
8
if File.exist?(File.join(current, CONFIG_FILE))
-
1
return current
-
end
-
-
# Check subdirectories for kjui.config.json
-
7
Dir.glob(File.join(current, '*', CONFIG_FILE)).each do |config_path|
-
1
if File.exist?(config_path)
-
1
return File.dirname(config_path)
-
end
-
end
-
-
6
current = File.dirname(current)
-
end
-
-
1
Dir.pwd
-
end
-
-
1
def check_and_update_ip
-
current_ip = get_local_ip
-
-
if current_ip && current_ip != @last_ip
-
update_config(current_ip)
-
update_android_configs(current_ip)
-
@last_ip = current_ip
-
puts "IP updated to: #{current_ip}"
-
end
-
rescue => e
-
puts "Error checking IP: #{e.message}"
-
end
-
-
1
def get_local_ip
-
# Try to get WiFi IP first (common interface names)
-
1
interfaces = ['wlan0', 'wlp2s0', 'wlp3s0', 'en0', 'en1', 'eth0', 'eth1']
-
-
1
interfaces.each do |interface|
-
4
ip = get_interface_ip(interface)
-
4
return ip if ip && !ip.start_with?('127.')
-
end
-
-
# Fallback: get any non-localhost IP
-
Socket.ip_address_list.each do |addr|
-
if addr.ipv4? && !addr.ipv4_loopback? && !addr.ipv4_multicast?
-
return addr.ip_address
-
end
-
end
-
-
nil
-
end
-
-
1
def get_interface_ip(interface)
-
4
Socket.getifaddrs.each do |ifaddr|
-
153
if ifaddr.name == interface && ifaddr.addr&.ipv4?
-
1
return ifaddr.addr.ip_address
-
end
-
end
-
nil
-
rescue
-
nil
-
end
-
-
1
def update_config(ip)
-
2
config = if File.exist?(@config_path)
-
1
JSON.parse(File.read(@config_path))
-
else
-
1
{}
-
end
-
-
2
config['hotloader'] ||= {}
-
2
config['hotloader']['ip'] = ip
-
2
config['hotloader']['port'] ||= 8081
-
2
config['hotloader']['enabled'] = true
-
-
2
File.write(@config_path, JSON.pretty_generate(config))
-
end
-
-
1
def update_android_configs(ip)
-
# Load config to get port
-
1
config = if File.exist?(@config_path)
-
JSON.parse(File.read(@config_path))
-
else
-
1
{}
-
end
-
1
port = config.dig('hotloader', 'port') || 8081
-
-
# Update local.properties if it exists
-
1
local_props = File.join(@project_root, 'local.properties')
-
1
if File.exist?(local_props)
-
1
content = File.read(local_props)
-
-
# Remove old hotloader.ip line if exists
-
1
content.gsub!(/^hotloader\.ip=.*$/, '')
-
1
content.gsub!(/^hotloader\.port=.*$/, '')
-
-
# Add new lines
-
1
content += "\nhotloader.ip=#{ip}"
-
1
content += "\nhotloader.port=#{port}"
-
-
1
File.write(local_props, content)
-
end
-
-
# Update any BuildConfig or resource files
-
1
update_build_config(ip)
-
end
-
-
1
def update_build_config(ip)
-
# Load config to get source directory and port
-
2
config = if File.exist?(@config_path)
-
1
JSON.parse(File.read(@config_path))
-
else
-
1
{}
-
end
-
-
2
source_dir = config['source_directory'] || 'src/main'
-
2
port = config.dig('hotloader', 'port') || 8081
-
-
# Create or update hotloader config in assets
-
2
assets_dir = File.join(@project_root, source_dir, 'assets')
-
2
FileUtils.mkdir_p(assets_dir)
-
-
2
hotloader_config = File.join(assets_dir, 'hotloader.json')
-
hotloader_json = {
-
2
'ip' => ip,
-
'port' => port,
-
'enabled' => true,
-
'websocket_endpoint' => "ws://#{ip}:#{port}",
-
'http_endpoint' => "http://#{ip}:#{port}"
-
}
-
-
2
File.write(hotloader_config, JSON.pretty_generate(hotloader_json))
-
end
-
end
-
end
-
end
-
1
require 'digest'
-
1
require 'fileutils'
-
1
require_relative 'shape_drawable_generator'
-
1
require_relative 'ripple_drawable_generator'
-
1
require_relative 'state_list_drawable_generator'
-
1
require_relative 'drawable_hash_manager'
-
-
1
module DrawableGenerator
-
1
class Generator
-
1
def initialize(project_root)
-
59
@project_root = project_root
-
-
# Check if we're already in a sample-app directory or need to look for one
-
59
if File.exist?(File.join(project_root, 'src', 'main', 'res'))
-
38
@drawable_dir = File.join(project_root, 'src', 'main', 'res', 'drawable')
-
21
elsif File.exist?(File.join(project_root, 'sample-app', 'src', 'main', 'res'))
-
@drawable_dir = File.join(project_root, 'sample-app', 'src', 'main', 'res', 'drawable')
-
21
elsif File.exist?(File.join(project_root, 'app', 'src', 'main', 'res'))
-
@drawable_dir = File.join(project_root, 'app', 'src', 'main', 'res', 'drawable')
-
else
-
# Default fallback
-
21
@drawable_dir = File.join(project_root, 'src', 'main', 'res', 'drawable')
-
end
-
-
59
@hash_manager = DrawableHashManager.new(@drawable_dir)
-
59
@shape_generator = ShapeDrawableGenerator.new
-
59
@ripple_generator = RippleDrawableGenerator.new
-
59
@state_list_generator = StateListDrawableGenerator.new
-
-
59
ensure_drawable_directory
-
end
-
-
1
def generate_for_component(json_data, component_type)
-
6
drawables = []
-
-
# Check if we need a ripple effect drawable
-
6
if needs_ripple?(json_data, component_type)
-
4
drawable_name = generate_ripple_drawable(json_data, component_type)
-
4
drawables << drawable_name if drawable_name
-
end
-
-
# Check if we need a shape drawable
-
6
if needs_shape?(json_data)
-
3
drawable_name = generate_shape_drawable(json_data, component_type)
-
3
drawables << drawable_name if drawable_name
-
end
-
-
# Check if we need a state list drawable
-
6
if needs_state_list?(json_data)
-
drawable_name = generate_state_list_drawable(json_data, component_type)
-
drawables << drawable_name if drawable_name
-
end
-
-
6
drawables.first # Return the primary drawable (usually state list or ripple)
-
end
-
-
1
def get_background_drawable(json_data, component_type)
-
5
return nil unless json_data
-
-
# Priority order: state list > ripple > shape > color
-
4
if needs_state_list?(json_data)
-
1
generate_state_list_drawable(json_data, component_type)
-
3
elsif needs_ripple?(json_data, component_type)
-
1
generate_ripple_drawable(json_data, component_type)
-
2
elsif needs_shape?(json_data)
-
1
generate_shape_drawable(json_data, component_type)
-
else
-
nil
-
end
-
end
-
-
1
private
-
-
1
def ensure_drawable_directory
-
59
FileUtils.mkdir_p(@drawable_dir) unless Dir.exist?(@drawable_dir)
-
end
-
-
1
def needs_ripple?(json_data, component_type)
-
17
return false unless json_data
-
-
# Check for click handlers
-
15
has_click_handler = json_data['onClick'] || json_data['onclick']
-
-
# Certain component types should have ripple by default
-
15
clickable_components = ['Button', 'ImageButton', 'Card', 'ListItem']
-
15
is_clickable_component = clickable_components.include?(component_type)
-
-
15
has_click_handler || is_clickable_component
-
end
-
-
1
def needs_shape?(json_data)
-
15
return false unless json_data
-
-
# Check for shape-related attributes
-
13
json_data['cornerRadius'] ||
-
json_data['borderWidth'] ||
-
json_data['borderColor'] ||
-
json_data['background']&.start_with?('#') ||
-
json_data['gradient']
-
end
-
-
1
def needs_state_list?(json_data)
-
17
return false unless json_data
-
-
# Check for state-specific attributes
-
15
json_data['disabledBackground'] ||
-
json_data['tapBackground'] ||
-
json_data['selectedBackground'] ||
-
json_data['pressedBackground'] ||
-
json_data['focusedBackground']
-
end
-
-
1
def generate_ripple_drawable(json_data, component_type)
-
# Generate content based on attributes
-
5
drawable_content = @ripple_generator.generate(json_data, component_type)
-
5
return nil unless drawable_content
-
-
# Generate hash-based filename
-
5
drawable_hash = @hash_manager.generate_hash(drawable_content)
-
5
drawable_name = "ripple_#{drawable_hash}"
-
-
# Check if drawable already exists
-
5
if @hash_manager.drawable_exists?(drawable_name)
-
return drawable_name
-
end
-
-
# Write the drawable file
-
5
drawable_path = File.join(@drawable_dir, "#{drawable_name}.xml")
-
5
File.write(drawable_path, drawable_content)
-
5
@hash_manager.register_drawable(drawable_name, drawable_content)
-
-
5
drawable_name
-
end
-
-
1
def generate_shape_drawable(json_data, component_type)
-
# Generate content based on attributes
-
5
drawable_content = @shape_generator.generate(json_data)
-
5
return nil unless drawable_content
-
-
# Generate hash-based filename
-
5
drawable_hash = @hash_manager.generate_hash(drawable_content)
-
5
drawable_name = "shape_#{drawable_hash}"
-
-
# Check if drawable already exists
-
5
if @hash_manager.drawable_exists?(drawable_name)
-
return drawable_name
-
end
-
-
# Write the drawable file
-
5
drawable_path = File.join(@drawable_dir, "#{drawable_name}.xml")
-
5
File.write(drawable_path, drawable_content)
-
5
@hash_manager.register_drawable(drawable_name, drawable_content)
-
-
5
drawable_name
-
end
-
-
1
def generate_state_list_drawable(json_data, component_type)
-
# Generate content based on attributes
-
1
drawable_content = @state_list_generator.generate(json_data, self)
-
1
return nil unless drawable_content
-
-
# Generate hash-based filename
-
1
drawable_hash = @hash_manager.generate_hash(drawable_content)
-
1
drawable_name = "selector_#{drawable_hash}"
-
-
# Check if drawable already exists
-
1
if @hash_manager.drawable_exists?(drawable_name)
-
return drawable_name
-
end
-
-
# Write the drawable file
-
1
drawable_path = File.join(@drawable_dir, "#{drawable_name}.xml")
-
1
File.write(drawable_path, drawable_content)
-
1
@hash_manager.register_drawable(drawable_name, drawable_content)
-
-
1
drawable_name
-
end
-
-
# Public method for state list generator to create sub-drawables
-
1
def create_shape_drawable_for_state(state_data)
-
2
return nil unless state_data
-
1
generate_shape_drawable(state_data, nil)
-
end
-
end
-
end
-
1
require 'digest'
-
1
require 'json'
-
-
1
module DrawableGenerator
-
1
class DrawableHashManager
-
1
HASH_REGISTRY_FILE = '.drawable_hashes.json'
-
-
1
def initialize(drawable_dir)
-
59
@drawable_dir = drawable_dir
-
59
@registry_path = File.join(@drawable_dir, HASH_REGISTRY_FILE)
-
59
@registry = load_registry
-
59
@session_cache = {}
-
end
-
-
1
def generate_hash(content)
-
# Generate a short hash from the content
-
22
full_hash = Digest::SHA256.hexdigest(content)
-
# Use first 8 characters for readability while maintaining uniqueness
-
22
full_hash[0..7]
-
end
-
-
1
def drawable_exists?(drawable_name)
-
# Check session cache first
-
11
return true if @session_cache[drawable_name]
-
-
# Check file system
-
11
file_path = File.join(@drawable_dir, "#{drawable_name}.xml")
-
11
exists = File.exist?(file_path)
-
-
# Update cache if exists
-
11
@session_cache[drawable_name] = true if exists
-
-
11
exists
-
end
-
-
1
def register_drawable(drawable_name, content)
-
# Add to session cache
-
11
@session_cache[drawable_name] = true
-
-
# Add to registry with metadata
-
11
@registry[drawable_name] = {
-
'hash' => generate_hash(content),
-
'created_at' => Time.now.to_s,
-
'content_hash' => Digest::MD5.hexdigest(content)
-
}
-
-
11
save_registry
-
end
-
-
1
def find_existing_drawable(content)
-
content_hash = Digest::MD5.hexdigest(content)
-
-
# Search registry for matching content
-
@registry.each do |name, data|
-
if data['content_hash'] == content_hash
-
# Verify file still exists
-
if drawable_exists?(name)
-
return name
-
else
-
# Clean up orphaned registry entry
-
@registry.delete(name)
-
end
-
end
-
end
-
-
nil
-
end
-
-
1
def cleanup_orphaned_drawables
-
orphaned = []
-
-
@registry.each do |name, _data|
-
file_path = File.join(@drawable_dir, "#{name}.xml")
-
unless File.exist?(file_path)
-
orphaned << name
-
end
-
end
-
-
orphaned.each { |name| @registry.delete(name) }
-
save_registry if orphaned.any?
-
-
orphaned
-
end
-
-
1
def list_drawables
-
drawables = []
-
-
Dir.glob(File.join(@drawable_dir, '*.xml')).each do |file|
-
name = File.basename(file, '.xml')
-
next if name == 'ic_launcher_foreground' # Skip system drawables
-
next if name == 'ic_launcher_background'
-
-
drawables << {
-
name: name,
-
path: file,
-
size: File.size(file),
-
modified: File.mtime(file)
-
}
-
end
-
-
drawables.sort_by { |d| d[:name] }
-
end
-
-
1
def get_usage_stats
-
stats = {
-
total_drawables: 0,
-
shape_drawables: 0,
-
ripple_drawables: 0,
-
selector_drawables: 0,
-
total_size: 0,
-
reuse_count: 0
-
}
-
-
list_drawables.each do |drawable|
-
stats[:total_drawables] += 1
-
stats[:total_size] += drawable[:size]
-
-
case drawable[:name]
-
when /^shape_/
-
stats[:shape_drawables] += 1
-
when /^ripple_/
-
stats[:ripple_drawables] += 1
-
when /^selector_/
-
stats[:selector_drawables] += 1
-
end
-
end
-
-
# Count reuses based on session cache
-
stats[:reuse_count] = @session_cache.size
-
-
stats
-
end
-
-
1
private
-
-
1
def load_registry
-
59
return {} unless File.exist?(@registry_path)
-
-
begin
-
JSON.parse(File.read(@registry_path))
-
rescue JSON::ParserError
-
{}
-
end
-
end
-
-
1
def save_registry
-
11
File.write(@registry_path, JSON.pretty_generate(@registry))
-
rescue => e
-
puts "Warning: Failed to save drawable registry: #{e.message}"
-
end
-
end
-
end
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module DrawableGenerator
-
1
class RippleDrawableGenerator
-
1
def generate(json_data, component_type)
-
26
return nil unless json_data
-
-
25
xml = []
-
25
xml << '<?xml version="1.0" encoding="utf-8"?>'
-
25
xml << '<ripple xmlns:android="http://schemas.android.com/apk/res/android"'
-
-
# Ripple color
-
25
ripple_color = determine_ripple_color(json_data, component_type)
-
25
xml << " android:color=\"#{ripple_color}\">"
-
-
# Content mask and background
-
25
if needs_mask?(json_data, component_type)
-
10
generate_mask_content(xml, json_data)
-
else
-
15
generate_background_content(xml, json_data)
-
end
-
-
25
xml << '</ripple>'
-
25
xml.join("\n")
-
end
-
-
1
private
-
-
1
def determine_ripple_color(json_data, component_type)
-
# Check for explicit ripple color
-
25
if json_data['rippleColor']
-
1
return parse_color(json_data['rippleColor'])
-
end
-
-
# Check for tap background (can be used as ripple hint)
-
24
if json_data['tapBackground']
-
1
return parse_color(json_data['tapBackground'])
-
end
-
-
# Default ripple colors based on component type
-
23
case component_type
-
when 'Button'
-
11
if json_data['background'] && json_data['background'].start_with?('#')
-
# Light ripple for dark backgrounds, dark ripple for light
-
4
return is_dark_color?(json_data['background']) ? '#40FFFFFF' : '#40000000'
-
end
-
7
return '?attr/colorControlHighlight'
-
when 'Card', 'ListItem'
-
3
return '?attr/colorControlHighlight'
-
else
-
# Default semi-transparent ripple
-
9
return '#20000000'
-
end
-
end
-
-
1
def needs_mask?(json_data, component_type)
-
# Use mask for borderless ripples or specific components
-
30
json_data['rippleBorderless'] == true ||
-
component_type == 'ImageButton' ||
-
24
(component_type == 'Button' && !json_data['background'])
-
end
-
-
1
def generate_mask_content(xml, json_data)
-
10
xml << ' <item android:id="@android:id/mask">'
-
-
10
if json_data['cornerRadius'] || json_data['shape']
-
1
xml << ' <shape android:shape="rectangle">'
-
-
1
if json_data['cornerRadius']
-
1
radius = parse_dimension(json_data['cornerRadius'])
-
1
xml << " <corners android:radius=\"#{radius}\" />"
-
end
-
-
1
xml << ' <solid android:color="@android:color/white" />'
-
1
xml << ' </shape>'
-
else
-
9
xml << ' <color android:color="@android:color/white" />'
-
end
-
-
10
xml << ' </item>'
-
end
-
-
1
def generate_background_content(xml, json_data)
-
# Add background item if specified
-
15
if json_data['background'] || json_data['cornerRadius'] || json_data['borderWidth']
-
11
xml << ' <item>'
-
-
11
if json_data['cornerRadius'] || json_data['borderWidth']
-
3
generate_shape_item(xml, json_data)
-
8
elsif json_data['background']
-
8
if json_data['background'].start_with?('#')
-
7
xml << " <color android:color=\"#{json_data['background']}\" />"
-
1
elsif json_data['background'].start_with?('@')
-
1
xml << " <color android:color=\"#{json_data['background']}\" />"
-
else
-
color = parse_color(json_data['background'])
-
xml << " <color android:color=\"#{color}\" />"
-
end
-
end
-
-
11
xml << ' </item>'
-
end
-
end
-
-
1
def generate_shape_item(xml, json_data)
-
3
xml << ' <shape android:shape="rectangle">'
-
-
# Corner radius
-
3
if json_data['cornerRadius']
-
2
radius = parse_dimension(json_data['cornerRadius'])
-
2
xml << " <corners android:radius=\"#{radius}\" />"
-
end
-
-
# Background color
-
3
if json_data['background']
-
2
color = parse_color(json_data['background'])
-
2
xml << " <solid android:color=\"#{color}\" />"
-
else
-
1
xml << ' <solid android:color="@android:color/transparent" />'
-
end
-
-
# Border
-
3
if json_data['borderWidth'] && json_data['borderColor']
-
1
width = parse_dimension(json_data['borderWidth'])
-
1
color = parse_color(json_data['borderColor'])
-
1
xml << ' <stroke'
-
1
xml << " android:width=\"#{width}\""
-
1
xml << " android:color=\"#{color}\" />"
-
end
-
-
3
xml << ' </shape>'
-
end
-
-
1
def is_dark_color?(color_str)
-
13
return false unless color_str&.start_with?('#')
-
-
# Remove # and parse hex
-
11
hex = color_str[1..]
-
-
# Handle different hex formats
-
11
if hex.length == 6
-
8
r = hex[0..1].to_i(16)
-
8
g = hex[2..3].to_i(16)
-
8
b = hex[4..5].to_i(16)
-
3
elsif hex.length == 8
-
# Skip alpha
-
1
r = hex[2..3].to_i(16)
-
1
g = hex[4..5].to_i(16)
-
1
b = hex[6..7].to_i(16)
-
2
elsif hex.length == 3
-
2
r = (hex[0] * 2).to_i(16)
-
2
g = (hex[1] * 2).to_i(16)
-
2
b = (hex[2] * 2).to_i(16)
-
else
-
return false
-
end
-
-
# Calculate luminance
-
11
luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
-
11
luminance < 0.5
-
end
-
-
1
def parse_dimension(value)
-
7
return '0dp' unless value
-
-
6
value_str = value.to_s
-
-
# Already has unit
-
6
return value_str if value_str =~ /\d+(dp|sp|px|dip|pt|in|mm)$/
-
-
# Just a number, add dp
-
5
return "#{value_str}dp" if value_str =~ /^\d+$/
-
-
value_str
-
end
-
-
1
def parse_color(value)
-
9
return '#000000' unless value
-
-
8
value_str = value.to_s
-
-
# Already a color reference
-
8
return value_str if value_str.start_with?('@color/', '?attr/')
-
-
# Special case for transparent
-
6
return '#00000000' if value_str == 'transparent'
-
-
# Use ResourceResolver to check for color resources
-
5
KjuiTools::Xml::Helpers::ResourceResolver.process_color(value_str)
-
end
-
end
-
end
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module DrawableGenerator
-
1
class ShapeDrawableGenerator
-
1
def generate(json_data)
-
23
return nil unless json_data
-
-
22
xml = []
-
22
xml << '<?xml version="1.0" encoding="utf-8"?>'
-
-
# Determine if we need a layer-list for gradient + border
-
22
if json_data['gradient'] && json_data['borderWidth']
-
2
generate_layered_shape(xml, json_data)
-
else
-
20
generate_simple_shape(xml, json_data)
-
end
-
-
22
xml.join("\n")
-
end
-
-
1
private
-
-
1
def generate_simple_shape(xml, json_data)
-
20
xml << '<shape xmlns:android="http://schemas.android.com/apk/res/android"'
-
20
xml << ' android:shape="rectangle">'
-
-
# Corner radius
-
20
if json_data['cornerRadius']
-
4
radius = parse_dimension(json_data['cornerRadius'])
-
4
xml << " <corners android:radius=\"#{radius}\" />"
-
end
-
-
# Background color or gradient
-
20
if json_data['gradient']
-
5
generate_gradient(xml, json_data['gradient'])
-
15
elsif json_data['background']
-
6
color = parse_color(json_data['background'])
-
6
xml << " <solid android:color=\"#{color}\" />"
-
end
-
-
# Border
-
20
if json_data['borderWidth'] && json_data['borderColor']
-
1
width = parse_dimension(json_data['borderWidth'])
-
1
color = parse_color(json_data['borderColor'])
-
1
xml << " <stroke"
-
1
xml << " android:width=\"#{width}\""
-
1
xml << " android:color=\"#{color}\" />"
-
end
-
-
# Padding
-
20
if json_data['padding']
-
1
padding = parse_dimension(json_data['padding'])
-
1
xml << " <padding"
-
1
xml << " android:left=\"#{padding}\""
-
1
xml << " android:top=\"#{padding}\""
-
1
xml << " android:right=\"#{padding}\""
-
1
xml << " android:bottom=\"#{padding}\" />"
-
19
elsif json_data['paddingLeft'] || json_data['paddingTop'] ||
-
json_data['paddingRight'] || json_data['paddingBottom']
-
2
xml << " <padding"
-
2
xml << " android:left=\"#{parse_dimension(json_data['paddingLeft'] || '0dp')}\""
-
2
xml << " android:top=\"#{parse_dimension(json_data['paddingTop'] || '0dp')}\""
-
2
xml << " android:right=\"#{parse_dimension(json_data['paddingRight'] || '0dp')}\""
-
2
xml << " android:bottom=\"#{parse_dimension(json_data['paddingBottom'] || '0dp')}\" />"
-
end
-
-
20
xml << '</shape>'
-
end
-
-
1
def generate_layered_shape(xml, json_data)
-
2
xml << '<layer-list xmlns:android="http://schemas.android.com/apk/res/android">'
-
-
# Background layer with gradient
-
2
xml << ' <item>'
-
2
xml << ' <shape android:shape="rectangle">'
-
-
2
if json_data['cornerRadius']
-
1
radius = parse_dimension(json_data['cornerRadius'])
-
1
xml << " <corners android:radius=\"#{radius}\" />"
-
end
-
-
2
generate_gradient(xml, json_data['gradient'], ' ')
-
-
2
xml << ' </shape>'
-
2
xml << ' </item>'
-
-
# Border layer
-
2
if json_data['borderWidth'] && json_data['borderColor']
-
2
xml << ' <item>'
-
2
xml << ' <shape android:shape="rectangle">'
-
-
2
if json_data['cornerRadius']
-
1
radius = parse_dimension(json_data['cornerRadius'])
-
1
xml << " <corners android:radius=\"#{radius}\" />"
-
end
-
-
2
width = parse_dimension(json_data['borderWidth'])
-
2
color = parse_color(json_data['borderColor'])
-
2
xml << " <stroke"
-
2
xml << " android:width=\"#{width}\""
-
2
xml << " android:color=\"#{color}\" />"
-
-
2
xml << ' </shape>'
-
2
xml << ' </item>'
-
end
-
-
2
xml << '</layer-list>'
-
end
-
-
1
def generate_gradient(xml, gradient_data, indent = ' ')
-
7
return unless gradient_data
-
-
# Parse gradient type
-
7
gradient_type = gradient_data['type'] || 'linear'
-
-
7
xml << "#{indent}<gradient"
-
-
7
case gradient_type.downcase
-
when 'linear'
-
5
xml << "#{indent} android:type=\"linear\""
-
5
angle = gradient_data['angle'] || 0
-
5
xml << "#{indent} android:angle=\"#{angle}\""
-
when 'radial'
-
1
xml << "#{indent} android:type=\"radial\""
-
1
radius = parse_dimension(gradient_data['radius'] || '100dp')
-
1
xml << "#{indent} android:gradientRadius=\"#{radius}\""
-
when 'sweep'
-
1
xml << "#{indent} android:type=\"sweep\""
-
end
-
-
# Colors
-
7
if gradient_data['startColor']
-
4
color = parse_color(gradient_data['startColor'])
-
4
xml << "#{indent} android:startColor=\"#{color}\""
-
end
-
-
7
if gradient_data['centerColor']
-
1
color = parse_color(gradient_data['centerColor'])
-
1
xml << "#{indent} android:centerColor=\"#{color}\""
-
end
-
-
7
if gradient_data['endColor']
-
4
color = parse_color(gradient_data['endColor'])
-
4
xml << "#{indent} android:endColor=\"#{color}\""
-
end
-
-
# Center position for radial
-
7
if gradient_type.downcase == 'radial'
-
1
centerX = gradient_data['centerX'] || 0.5
-
1
centerY = gradient_data['centerY'] || 0.5
-
1
xml << "#{indent} android:centerX=\"#{centerX}\""
-
1
xml << "#{indent} android:centerY=\"#{centerY}\""
-
end
-
-
7
xml << " />"
-
end
-
-
1
def parse_dimension(value)
-
25
return '0dp' unless value
-
-
24
value_str = value.to_s
-
-
# Already has unit
-
24
return value_str if value_str =~ /\d+(dp|sp|px|dip|pt|in|mm)$/
-
-
# Just a number, add dp
-
17
return "#{value_str}dp" if value_str =~ /^\d+$/
-
-
value_str
-
end
-
-
1
def parse_color(value)
-
21
return '#000000' unless value
-
-
20
value_str = value.to_s
-
-
# Already a color reference
-
20
return value_str if value_str.start_with?('@color/')
-
-
# Special case for transparent
-
19
return '#00000000' if value_str == 'transparent'
-
-
# Use ResourceResolver to check for color resources
-
18
KjuiTools::Xml::Helpers::ResourceResolver.process_color(value_str)
-
end
-
end
-
end
-
1
require_relative '../helpers/resource_resolver'
-
-
1
module DrawableGenerator
-
1
class StateListDrawableGenerator
-
1
def generate(json_data, parent_generator)
-
20
return nil unless json_data
-
-
19
@parent_generator = parent_generator
-
-
19
xml = []
-
19
xml << '<?xml version="1.0" encoding="utf-8"?>'
-
19
xml << '<selector xmlns:android="http://schemas.android.com/apk/res/android">'
-
-
# Order matters in state list - most specific states first
-
-
# Disabled state
-
19
if json_data['disabledBackground'] || json_data['disabledColor']
-
3
generate_state_item(xml,
-
state: 'disabled',
-
background: json_data['disabledBackground'],
-
color: json_data['disabledColor'],
-
original_data: json_data
-
)
-
end
-
-
# Pressed/Tap state
-
19
if json_data['tapBackground'] || json_data['pressedBackground'] || json_data['tapColor']
-
3
generate_state_item(xml,
-
state: 'pressed',
-
background: json_data['tapBackground'] || json_data['pressedBackground'],
-
color: json_data['tapColor'],
-
original_data: json_data
-
)
-
end
-
-
# Selected state
-
19
if json_data['selectedBackground'] || json_data['selectedColor']
-
2
generate_state_item(xml,
-
state: 'selected',
-
background: json_data['selectedBackground'],
-
color: json_data['selectedColor'],
-
original_data: json_data
-
)
-
end
-
-
# Focused state
-
19
if json_data['focusedBackground'] || json_data['focusedColor']
-
2
generate_state_item(xml,
-
state: 'focused',
-
background: json_data['focusedBackground'],
-
color: json_data['focusedColor'],
-
original_data: json_data
-
)
-
end
-
-
# Checked state (for checkboxes, radio buttons, switches)
-
19
if json_data['checkedBackground'] || json_data['checkedColor']
-
2
generate_state_item(xml,
-
state: 'checked',
-
background: json_data['checkedBackground'],
-
color: json_data['checkedColor'],
-
original_data: json_data
-
)
-
end
-
-
# Default state (always last)
-
19
generate_default_state(xml, json_data)
-
-
19
xml << '</selector>'
-
19
xml.join("\n")
-
end
-
-
1
private
-
-
1
def generate_state_item(xml, state:, background:, color:, original_data:)
-
12
return unless background || color
-
-
# Build state attributes
-
12
state_attrs = build_state_attributes(state)
-
-
12
xml << " <item #{state_attrs}>"
-
-
12
if background
-
7
if needs_shape?(background, original_data)
-
# Generate a shape drawable for this state
-
generate_state_shape(xml, background, original_data)
-
else
-
# Simple color
-
7
color_value = parse_color(background)
-
7
xml << " <color android:color=\"#{color_value}\" />"
-
end
-
5
elsif color
-
# Text color selector item
-
5
color_value = parse_color(color)
-
5
xml << " <color android:color=\"#{color_value}\" />"
-
end
-
-
12
xml << ' </item>'
-
end
-
-
1
def generate_default_state(xml, json_data)
-
19
xml << ' <item>'
-
-
19
if json_data['background'] || json_data['cornerRadius'] || json_data['borderWidth']
-
4
if needs_shape?(json_data['background'], json_data)
-
2
generate_state_shape(xml, json_data['background'], json_data)
-
else
-
# Simple color background
-
2
color = parse_color(json_data['background'] || '#FFFFFF')
-
2
xml << " <color android:color=\"#{color}\" />"
-
end
-
else
-
# Transparent default
-
15
xml << ' <color android:color="@android:color/transparent" />'
-
end
-
-
19
xml << ' </item>'
-
end
-
-
1
def generate_state_shape(xml, background, original_data)
-
2
xml << ' <shape android:shape="rectangle">'
-
-
# Corner radius from original data
-
2
if original_data['cornerRadius']
-
1
radius = parse_dimension(original_data['cornerRadius'])
-
1
xml << " <corners android:radius=\"#{radius}\" />"
-
end
-
-
# Background color or gradient
-
2
if background.is_a?(Hash) && background['gradient']
-
generate_gradient(xml, background['gradient'])
-
2
elsif background
-
2
color = parse_color(background)
-
2
xml << " <solid android:color=\"#{color}\" />"
-
end
-
-
# Border from original data (consistent across states)
-
2
if original_data['borderWidth'] && original_data['borderColor']
-
1
width = parse_dimension(original_data['borderWidth'])
-
1
color = parse_color(original_data['borderColor'])
-
1
xml << ' <stroke'
-
1
xml << " android:width=\"#{width}\""
-
1
xml << " android:color=\"#{color}\" />"
-
end
-
-
2
xml << ' </shape>'
-
end
-
-
1
def generate_gradient(xml, gradient_data)
-
return unless gradient_data
-
-
gradient_type = gradient_data['type'] || 'linear'
-
-
xml << ' <gradient'
-
-
case gradient_type.downcase
-
when 'linear'
-
xml << ' android:type="linear"'
-
angle = gradient_data['angle'] || 0
-
xml << " android:angle=\"#{angle}\""
-
when 'radial'
-
xml << ' android:type="radial"'
-
radius = parse_dimension(gradient_data['radius'] || '100dp')
-
xml << " android:gradientRadius=\"#{radius}\""
-
when 'sweep'
-
xml << ' android:type="sweep"'
-
end
-
-
# Colors
-
if gradient_data['startColor']
-
color = parse_color(gradient_data['startColor'])
-
xml << " android:startColor=\"#{color}\""
-
end
-
-
if gradient_data['centerColor']
-
color = parse_color(gradient_data['centerColor'])
-
xml << " android:centerColor=\"#{color}\""
-
end
-
-
if gradient_data['endColor']
-
color = parse_color(gradient_data['endColor'])
-
xml << " android:endColor=\"#{color}\""
-
end
-
-
xml << ' />'
-
end
-
-
1
def build_state_attributes(state)
-
19
case state
-
when 'disabled'
-
4
'android:state_enabled="false"'
-
when 'pressed'
-
4
'android:state_pressed="true"'
-
when 'selected'
-
3
'android:state_selected="true"'
-
when 'focused'
-
3
'android:state_focused="true"'
-
when 'checked'
-
3
'android:state_checked="true"'
-
when 'activated'
-
1
'android:state_activated="true"'
-
else
-
1
''
-
end
-
end
-
-
1
def needs_shape?(background, original_data)
-
15
return true if original_data['cornerRadius']
-
13
return true if original_data['borderWidth']
-
11
return true if background.is_a?(Hash) && background['gradient']
-
10
false
-
end
-
-
1
def parse_dimension(value)
-
5
return '0dp' unless value
-
-
4
value_str = value.to_s
-
-
# Already has unit
-
4
return value_str if value_str =~ /\d+(dp|sp|px|dip|pt|in|mm)$/
-
-
# Just a number, add dp
-
3
return "#{value_str}dp" if value_str =~ /^\d+$/
-
-
value_str
-
end
-
-
1
def parse_color(value)
-
21
return '#000000' unless value
-
-
20
value_str = value.to_s
-
-
# Already a color reference
-
20
return value_str if value_str.start_with?('@color/', '?attr/')
-
-
# Special case for transparent
-
18
return '#00000000' if value_str == 'transparent'
-
-
# Use ResourceResolver to check for color resources
-
17
KjuiTools::Xml::Helpers::ResourceResolver.process_color(value_str)
-
end
-
end
-
end
-
#!/usr/bin/env ruby
-
-
1
require 'json'
-
1
require_relative 'mappers/dimension_mapper'
-
1
require_relative 'mappers/text_mapper'
-
1
require_relative 'mappers/layout_mapper'
-
1
require_relative 'mappers/style_mapper'
-
1
require_relative 'mappers/input_mapper'
-
-
1
module XmlGenerator
-
1
class AttributeMapper
-
1
def initialize(drawable_generator = nil, string_resource_manager = nil)
-
64
@dimension_mapper = Mappers::DimensionMapper.new
-
64
@text_mapper = Mappers::TextMapper.new(string_resource_manager)
-
64
@layout_mapper = Mappers::LayoutMapper.new(@dimension_mapper)
-
64
@style_mapper = Mappers::StyleMapper.new(@text_mapper, drawable_generator)
-
64
@input_mapper = Mappers::InputMapper.new
-
64
@drawable_generator = drawable_generator
-
64
@string_resource_manager = string_resource_manager
-
-
64
@attribute_map = create_attribute_map
-
end
-
-
1
def map_dimension(value)
-
30
@dimension_mapper.map_dimension(value)
-
end
-
-
1
def map_attribute(key, value, component_type, parent_type = nil, json_element = nil)
-
# Skip problematic data binding expressions for specific attributes
-
25
if should_skip_binding?(key, value, component_type)
-
4
log_skipped_binding(key, value, component_type)
-
4
puts "Skipping binding: #{key}=#{value} for #{component_type}" if ENV['DEBUG']
-
4
return nil
-
end
-
-
# Try layout attributes first (includes dimensions, padding, margin, alignment)
-
21
result = @layout_mapper.map_layout_attributes(key, value, component_type, parent_type)
-
21
return result if result
-
-
# Try alignment attributes
-
20
result = @layout_mapper.map_alignment_attributes(key, value, parent_type)
-
20
return result if result
-
-
# Try text attributes
-
18
result = @text_mapper.map_text_attributes(key, value, component_type)
-
18
return result if result
-
-
# Try style attributes (with json_element for drawable generation)
-
15
result = @style_mapper.map_style_attributes(key, value, json_element, component_type)
-
15
return result if result
-
-
# Try input attributes
-
14
result = @input_mapper.map_input_attributes(key, value)
-
14
return result if result
-
-
# Custom component properties (store as tag or tools attribute)
-
14
case key
-
when 'title'
-
# Don't use tools: namespace for data binding expressions
-
2
if value.to_s.start_with?('@{')
-
1
return nil # Skip tools attributes with data binding
-
end
-
1
return { namespace: 'tools', name: 'title', value: value }
-
when 'count'
-
# Don't use tools: namespace for data binding expressions
-
1
if value.to_s.start_with?('@{')
-
return nil # Skip tools attributes with data binding
-
end
-
1
return { namespace: 'tools', name: 'count', value: value.to_s }
-
when /^constraint/
-
3
return map_constraint_attribute(key, value)
-
else
-
# Check if it's in the standard map
-
8
if @attribute_map[key]
-
5
mapped = @attribute_map[key]
-
return {
-
5
namespace: mapped[:namespace] || 'android',
-
name: mapped[:name],
-
value: convert_value(value, mapped[:type])
-
}
-
end
-
end
-
-
nil
-
end
-
-
1
private
-
-
1
def should_skip_binding?(key, value, component_type)
-
25
return false unless value.to_s.include?('@{')
-
-
# List of problematic bindings that need to be skipped
-
problematic_bindings = [
-
# RecyclerView items binding - Skip this as it needs complex adapter implementation
-
5
{ key: 'items', component: 'RecyclerView' },
-
{ key: 'items', component: 'Collection' },
-
# StatusColor binding - Compose UI Color type not supported in data binding
-
{ key: 'tint', value_contains: 'statusColor' },
-
{ key: 'color', value_contains: 'statusColor' }, # color is sometimes mapped to tint
-
# Visibility binding - String type not supported
-
{ key: 'visibility', value_contains: '@{' },
-
# Progress binding - double type not supported
-
{ key: 'progress', value_contains: '@{' },
-
# Slider value binding (maps to progress) - double type not supported
-
{ key: 'value', component: 'Slider', value_contains: '@{' }
-
]
-
-
5
problematic_bindings.any? do |binding|
-
22
if binding[:component]
-
10
key == binding[:key] && component_type&.include?(binding[:component])
-
12
elsif binding[:value_contains]
-
12
key == binding[:key] && value.to_s.include?(binding[:value_contains])
-
elsif binding[:type]
-
key == binding[:key] && value.to_s.include?('.') # Assumes object property access
-
else
-
key == binding[:key]
-
end
-
end
-
end
-
-
1
def log_skipped_binding(key, value, component_type)
-
4
@skipped_bindings ||= []
-
4
@skipped_bindings << {
-
attribute: key,
-
value: value,
-
component: component_type,
-
reason: 'Requires custom binding adapter'
-
}
-
-
# Write to a file that can be accessed later
-
4
File.open('/tmp/skipped_bindings.json', 'w') do |f|
-
4
f.write(@skipped_bindings.to_json)
-
end
-
end
-
-
1
def create_attribute_map
-
{
-
# Additional mappings not covered by specific mappers
-
64
'contentDescription' => { name: 'contentDescription', type: 'string' },
-
'tag' => { name: 'tag', type: 'string' },
-
'transitionName' => { name: 'transitionName', type: 'string' },
-
'elevation' => { name: 'elevation', type: 'dimension' },
-
'translationZ' => { name: 'translationZ', type: 'dimension' },
-
'rotation' => { name: 'rotation', type: 'float' },
-
'rotationX' => { name: 'rotationX', type: 'float' },
-
'rotationY' => { name: 'rotationY', type: 'float' },
-
'scaleX' => { name: 'scaleX', type: 'float' },
-
'scaleY' => { name: 'scaleY', type: 'float' }
-
}
-
end
-
-
1
def convert_value(value, type)
-
5
case type
-
when 'dimension'
-
1
@dimension_mapper.convert_dimension(value)
-
when 'float'
-
2
value.to_f.to_s
-
when 'integer'
-
value.to_i.to_s
-
when 'boolean'
-
value.to_s
-
else
-
2
value
-
end
-
end
-
-
1
def map_constraint_attribute(key, value)
-
# ConstraintLayout attributes mapping
-
constraint_map = {
-
3
'constraintStartToStartOf' => 'layout_constraintStart_toStartOf',
-
'constraintEndToEndOf' => 'layout_constraintEnd_toEndOf',
-
'constraintTopToTopOf' => 'layout_constraintTop_toTopOf',
-
'constraintBottomToBottomOf' => 'layout_constraintBottom_toBottomOf',
-
'constraintStartToEndOf' => 'layout_constraintStart_toEndOf',
-
'constraintEndToStartOf' => 'layout_constraintEnd_toStartOf',
-
'constraintTopToBottomOf' => 'layout_constraintTop_toBottomOf',
-
'constraintBottomToTopOf' => 'layout_constraintBottom_toTopOf'
-
}
-
-
3
if constraint_map[key]
-
3
constraint_value = value == 'parent' ? 'parent' : "@id/#{value}"
-
3
return { namespace: 'app', name: constraint_map[key], value: constraint_value }
-
end
-
-
nil
-
end
-
end
-
end
-
#!/usr/bin/env ruby
-
-
1
module XmlGenerator
-
1
class BindingParser
-
1
def initialize
-
34
@bindings = []
-
end
-
-
1
def parse(value)
-
# Convert @{variable} syntax to Android data binding
-
11
if value.start_with?('@{') && value.end_with?('}')
-
# Extract the binding expression
-
10
expression = value[2..-2]
-
-
# Track the binding
-
10
@bindings << expression
-
-
# Return Android data binding format
-
10
"@{#{convert_expression(expression)}}"
-
else
-
1
value
-
end
-
end
-
-
1
def get_bindings
-
1
@bindings.uniq
-
end
-
-
1
def has_bindings?
-
2
!@bindings.empty?
-
end
-
-
1
private
-
-
1
def convert_expression(expression)
-
# Handle different binding patterns
-
-
# Simple variable binding: @{userName} -> @{data.userName}
-
10
if expression.match?(/^\w+$/)
-
5
return "data.#{expression}"
-
end
-
-
# Property access: @{user.name} -> @{data.user.name}
-
5
if expression.match?(/^[\w.]+$/)
-
1
return "data.#{expression}"
-
end
-
-
# Method call: @{getUserName()} -> @{viewModel.getUserName()}
-
4
if expression.include?('(')
-
2
if expression.start_with?('viewModel.')
-
1
return expression
-
else
-
1
return "viewModel.#{expression}"
-
end
-
end
-
-
# Conditional expression: @{isVisible ? View.VISIBLE : View.GONE}
-
2
if expression.include?('?')
-
1
return process_conditional(expression)
-
end
-
-
# String concatenation: @{`Hello ${userName}`}
-
1
if expression.include?('${')
-
1
return process_string_template(expression)
-
end
-
-
# Default: return as is
-
expression
-
end
-
-
1
def process_conditional(expression)
-
# Convert conditional expressions
-
1
parts = expression.split(/\s*\?\s*/)
-
1
if parts.length == 2
-
1
condition = parts[0]
-
1
values = parts[1].split(/\s*:\s*/)
-
-
1
if values.length == 2
-
# Add data. prefix to condition if it's a simple variable
-
1
if condition.match?(/^\w+$/)
-
1
condition = "data.#{condition}"
-
end
-
-
# Process visibility values
-
1
true_value = process_value(values[0])
-
1
false_value = process_value(values[1])
-
-
1
return "#{condition} ? #{true_value} : #{false_value}"
-
end
-
end
-
-
expression
-
end
-
-
1
def process_string_template(expression)
-
# Convert string template: `Hello ${userName}` -> @{`Hello ` + data.userName}
-
1
if expression.start_with?('`') && expression.end_with?('`')
-
1
template = expression[1..-2]
-
-
# Replace ${variable} with ` + data.variable + `
-
1
template.gsub!(/\$\{(\w+)\}/) do |match|
-
1
"` + data.#{$1} + `"
-
end
-
-
1
"`#{template}`"
-
else
-
expression
-
end
-
end
-
-
1
def process_value(value)
-
# Process special values
-
2
case value.strip
-
when 'true', 'false'
-
value
-
when 'VISIBLE', 'View.VISIBLE'
-
1
'View.VISIBLE'
-
when 'INVISIBLE', 'View.INVISIBLE'
-
'View.INVISIBLE'
-
when 'GONE', 'View.GONE'
-
1
'View.GONE'
-
else
-
# Check if it's a simple variable
-
if value.match?(/^\w+$/)
-
"data.#{value}"
-
else
-
value
-
end
-
end
-
end
-
end
-
-
1
class DataBindingManager
-
1
def initialize
-
3
@variables = Set.new
-
3
@imports = Set.new
-
3
@converters = []
-
end
-
-
1
def add_variable(name, type = 'String')
-
1
@variables.add({ name: name, type: type })
-
end
-
-
1
def add_import(class_name)
-
1
@imports.add(class_name)
-
end
-
-
1
def add_converter(converter)
-
1
@converters << converter
-
end
-
-
1
def generate_data_binding_layout(xml_content)
-
# Wrap the layout in <layout> tags for data binding
-
doc = Nokogiri::XML(xml_content)
-
-
# Create new document with layout root
-
builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
-
xml.layout('xmlns:android' => 'http://schemas.android.com/apk/res/android',
-
'xmlns:app' => 'http://schemas.android.com/apk/res-auto',
-
'xmlns:tools' => 'http://schemas.android.com/tools') do
-
-
# Add data section
-
xml.data do
-
# Add imports
-
@imports.each do |import|
-
xml.import(type: import)
-
end
-
-
# Add variables
-
@variables.each do |var|
-
xml.variable(name: var[:name], type: var[:type])
-
end
-
-
# Add ViewModel variable
-
xml.variable(name: 'viewModel', type: "com.example.viewmodel.#{get_view_model_name}")
-
end
-
-
# Add the original layout content (without XML declaration)
-
xml << doc.root.to_xml
-
end
-
end
-
-
builder.to_xml(indent: 4)
-
end
-
-
1
private
-
-
1
def get_view_model_name
-
# Generate ViewModel class name from layout name
-
# This should be passed in or configured
-
'MainViewModel'
-
end
-
end
-
end
-
#!/usr/bin/env ruby
-
-
1
module XmlGenerator
-
1
class ComponentMapper
-
1
def initialize
-
@component_map = {
-
# Layout containers
-
# Note: 'View' is handled specially in map_component method
-
61
'HStack' => 'LinearLayout',
-
'VStack' => 'LinearLayout',
-
'ZStack' => 'FrameLayout',
-
'RelativeView' => 'RelativeLayout',
-
'ConstraintView' => 'androidx.constraintlayout.widget.ConstraintLayout',
-
'ScrollView' => 'ScrollView',
-
'HorizontalScrollView' => 'HorizontalScrollView',
-
-
# Basic components - Use Kjui custom views for font support
-
'Label' => 'com.kotlinjsonui.views.KjuiTextView',
-
'Text' => 'com.kotlinjsonui.views.KjuiTextView',
-
'Button' => 'com.kotlinjsonui.views.KjuiButton',
-
'ImageButton' => 'ImageButton',
-
'TextField' => 'com.kotlinjsonui.views.KjuiEditText',
-
'SecureField' => 'com.kotlinjsonui.views.KjuiEditText',
-
'TextView' => 'com.kotlinjsonui.views.KjuiEditText',
-
-
# Images
-
'Image' => 'ImageView',
-
'NetworkImage' => 'com.kotlinjsonui.views.KjuiNetworkImageView',
-
'CircleImage' => 'com.kotlinjsonui.views.KjuiCircleImageView',
-
-
# Selection components
-
'Switch' => 'Switch',
-
'Checkbox' => 'CheckBox',
-
'Radio' => 'RadioButton',
-
'RadioGroup' => 'RadioGroup',
-
'Segment' => 'com.google.android.material.tabs.TabLayout',
-
'Picker' => 'Spinner',
-
'SelectBox' => 'com.kotlinjsonui.views.KjuiSelectBox',
-
'DatePicker' => 'DatePicker',
-
'TimePicker' => 'TimePicker',
-
-
# Progress
-
'ProgressBar' => 'ProgressBar',
-
'Slider' => 'SeekBar',
-
'Rating' => 'RatingBar',
-
-
# Lists
-
'List' => 'androidx.recyclerview.widget.RecyclerView',
-
'Table' => 'androidx.recyclerview.widget.RecyclerView',
-
'Collection' => 'androidx.recyclerview.widget.RecyclerView',
-
'Grid' => 'GridLayout',
-
-
# Material Design components
-
'Card' => 'com.google.android.material.card.MaterialCardView',
-
'Chip' => 'com.google.android.material.chip.Chip',
-
'ChipGroup' => 'com.google.android.material.chip.ChipGroup',
-
'FloatingActionButton' => 'com.google.android.material.floatingactionbutton.FloatingActionButton',
-
'BottomNavigation' => 'com.google.android.material.bottomnavigation.BottomNavigationView',
-
'NavigationView' => 'com.google.android.material.navigation.NavigationView',
-
'AppBar' => 'com.google.android.material.appbar.AppBarLayout',
-
'Toolbar' => 'androidx.appcompat.widget.Toolbar',
-
'TabLayout' => 'com.google.android.material.tabs.TabLayout',
-
'TabView' => 'com.google.android.material.tabs.TabLayout',
-
-
# Special components
-
'SafeAreaView' => 'com.kotlinjsonui.views.KjuiSafeAreaView',
-
'GradientView' => 'com.kotlinjsonui.views.KjuiGradientView',
-
'BlurView' => 'com.kotlinjsonui.views.KjuiBlurView',
-
'WebView' => 'WebView',
-
'VideoView' => 'VideoView',
-
'MapView' => 'com.google.android.gms.maps.MapView',
-
'AdView' => 'com.google.android.gms.ads.AdView',
-
-
# Dividers and spacers
-
'Divider' => 'View',
-
'Spacer' => 'Space',
-
-
# Custom components (will be replaced with includes)
-
'Include' => 'include'
-
}
-
end
-
-
1
def map_component(type, json_element = nil)
-
# Special handling for View type
-
21
if type == 'View' && json_element
-
# Check if orientation is specified
-
5
if json_element['orientation']
-
1
return 'LinearLayout'
-
else
-
# Use ConstraintLayout instead of RelativeLayout for better positioning support
-
4
return 'androidx.constraintlayout.widget.ConstraintLayout'
-
end
-
end
-
-
# Check for custom component prefix
-
16
if type.start_with?('Custom')
-
1
return 'include'
-
end
-
-
# For unknown types, check if they have children
-
15
if !@component_map[type] && json_element && (json_element['child'] || json_element['children'])
-
1
return 'FrameLayout'
-
end
-
-
14
@component_map[type] || 'View'
-
end
-
-
1
def is_container?(type)
-
8
containers = ['View', 'HStack', 'VStack', 'ZStack', 'ScrollView',
-
'HorizontalScrollView', 'RelativeView', 'ConstraintView',
-
'Card', 'List', 'Table', 'Collection', 'Grid',
-
'RadioGroup', 'ChipGroup']
-
8
containers.include?(type)
-
end
-
-
1
def needs_adapter?(type)
-
4
['List', 'Table', 'Collection', 'RecyclerView'].include?(type)
-
end
-
-
1
def is_material_component?(android_class)
-
2
android_class.include?('com.google.android.material')
-
end
-
-
1
def get_layout_params_class(parent_type)
-
4
case parent_type
-
when 'RelativeLayout', 'RelativeView'
-
1
'RelativeLayout.LayoutParams'
-
when 'LinearLayout', 'View', 'HStack', 'VStack'
-
1
'LinearLayout.LayoutParams'
-
when 'FrameLayout', 'ZStack'
-
1
'FrameLayout.LayoutParams'
-
when 'ConstraintLayout', 'ConstraintView'
-
1
'ConstraintLayout.LayoutParams'
-
when 'GridLayout', 'Grid'
-
'GridLayout.LayoutParams'
-
else
-
'ViewGroup.LayoutParams'
-
end
-
end
-
-
1
def get_orientation(type)
-
3
case type
-
when 'HStack'
-
1
'horizontal'
-
when 'VStack', 'View'
-
1
'vertical'
-
else
-
nil
-
end
-
end
-
end
-
end
-
#!/usr/bin/env ruby
-
-
1
module XmlGenerator
-
1
class DataBindingHelper
-
1
def self.process_data_binding(value)
-
11
return nil if value.nil?
-
# Convert @{variable} to Android data binding format
-
10
if value.is_a?(String) && value.start_with?('@{') && value.end_with?('}')
-
# Already in binding format, just ensure proper data. prefix
-
7
expr = value[2..-2]
-
-
# Add data. prefix if it's a simple variable
-
7
if expr.match?(/^\w+$/)
-
2
"@{data.#{expr}}"
-
5
elsif expr.include?('(') && !expr.include?('viewModel.')
-
# Method call without viewModel prefix
-
2
"@{viewModel.#{expr}}"
-
else
-
# Keep as is (already has proper prefix or is complex expression)
-
3
value
-
end
-
else
-
3
value
-
end
-
end
-
end
-
end
-
#!/usr/bin/env ruby
-
-
1
require_relative 'data_binding_helper'
-
-
1
module XmlGenerator
-
1
class LayoutAttributeProcessor
-
1
def initialize(attribute_mapper)
-
42
@attribute_mapper = attribute_mapper
-
end
-
-
# Process layout dimensions with weight support
-
1
def process_dimensions(json_element, is_root, parent_orientation)
-
13
attrs = {}
-
-
13
has_weight = json_element['weight']
-
-
# Default dimensions
-
13
default_width = 'wrap_content'
-
13
default_height = 'wrap_content'
-
-
# If root element, default to match_parent
-
13
if is_root
-
4
default_width = 'match_parent'
-
4
default_height = 'match_parent'
-
# If weight is specified, set the dimension in the orientation direction to 0dp
-
9
elsif has_weight && parent_orientation
-
4
if parent_orientation == 'horizontal'
-
2
default_width = '0dp' if !json_element['width']
-
2
elsif parent_orientation == 'vertical'
-
2
default_height = '0dp' if !json_element['height']
-
end
-
end
-
-
13
attrs['android:layout_width'] = @attribute_mapper.map_dimension(
-
json_element['width'] || default_width
-
)
-
13
attrs['android:layout_height'] = @attribute_mapper.map_dimension(
-
json_element['height'] || default_height
-
)
-
-
13
attrs
-
end
-
-
# Process all attributes with gravity combination support
-
1
def process_attributes(json_element, parent_type)
-
12
attrs = {}
-
12
gravity_values = []
-
12
constraint_extras = []
-
12
has_constraint_specified = false
-
-
# Check if parent is ConstraintLayout
-
12
is_constraint_layout = parent_type&.include?('ConstraintLayout')
-
-
# Track which constraints have been set
-
12
constraint_flags = {
-
horizontal: false,
-
vertical: false
-
}
-
-
# Map all attributes
-
12
json_element.each do |key, value|
-
23
next if ['type', 'child', 'children', 'id', 'width', 'height', 'style', 'data', 'orientation'].include?(key)
-
-
9
android_attr = @attribute_mapper.map_attribute(key, value, json_element['type'], parent_type, json_element)
-
9
if android_attr
-
7
namespace, attr_name = android_attr[:namespace], android_attr[:name]
-
7
attr_value = android_attr[:value]
-
7
extra = android_attr[:extra]
-
-
# Handle data binding
-
7
if attr_value.is_a?(String) && attr_value.start_with?('@{')
-
attr_value = DataBindingHelper.process_data_binding(attr_value)
-
end
-
-
# Track if constraint attributes are being set
-
7
if is_constraint_layout && namespace == 'app'
-
2
if attr_name.include?('constraint')
-
2
has_constraint_specified = true
-
# Track horizontal constraints
-
2
if attr_name.include?('Start') || attr_name.include?('End') || attr_name.include?('Left') || attr_name.include?('Right')
-
1
constraint_flags[:horizontal] = true
-
end
-
# Track vertical constraints
-
2
if attr_name.include?('Top') || attr_name.include?('Bottom')
-
1
constraint_flags[:vertical] = true
-
end
-
end
-
end
-
-
# Check for alignment attributes that map to constraints
-
7
if is_constraint_layout && ['alignLeft', 'alignRight', 'alignTop', 'alignBottom', 'alignCenterHorizontal', 'alignCenterVertical', 'alignCenterInParent'].include?(key)
-
2
has_constraint_specified = true
-
2
if key == 'alignLeft' || key == 'alignRight' || key == 'alignCenterHorizontal'
-
1
constraint_flags[:horizontal] = true
-
end
-
2
if key == 'alignTop' || key == 'alignBottom' || key == 'alignCenterVertical'
-
1
constraint_flags[:vertical] = true
-
end
-
2
if key == 'alignCenterInParent'
-
constraint_flags[:horizontal] = true
-
constraint_flags[:vertical] = true
-
end
-
end
-
-
# Collect gravity values to combine them
-
7
if attr_name == 'layout_gravity' && parent_type == 'LinearLayout'
-
gravity_values << attr_value if value
-
7
elsif extra && is_constraint_layout
-
# Handle special ConstraintLayout cases that need multiple attributes
-
constraint_extras << { key: key, value: value, extra: extra }
-
# Still add the primary attribute
-
if namespace == 'android'
-
attrs["android:#{attr_name}"] = attr_value
-
elsif namespace == 'app'
-
attrs["app:#{attr_name}"] = attr_value
-
end
-
else
-
7
if namespace == 'android'
-
5
attrs["android:#{attr_name}"] = attr_value
-
2
elsif namespace == 'app'
-
2
attrs["app:#{attr_name}"] = attr_value
-
elsif namespace == 'tools'
-
attrs["tools:#{attr_name}"] = attr_value
-
else
-
attrs[attr_name] = attr_value
-
end
-
end
-
end
-
end
-
-
# Process ConstraintLayout special cases
-
12
if is_constraint_layout && constraint_extras.any?
-
constraint_extras.each do |item|
-
case item[:extra]
-
when 'center_horizontal'
-
# Add end constraint for horizontal centering
-
attrs['app:layout_constraintEnd_toEndOf'] = 'parent'
-
when 'center_vertical'
-
# Add bottom constraint for vertical centering
-
attrs['app:layout_constraintBottom_toBottomOf'] = 'parent'
-
when 'center_in_parent'
-
# Add all constraints for centering in parent
-
attrs['app:layout_constraintEnd_toEndOf'] = 'parent'
-
attrs['app:layout_constraintTop_toTopOf'] = 'parent'
-
attrs['app:layout_constraintBottom_toBottomOf'] = 'parent'
-
when 'center_vertical_to_view'
-
# Add bottom constraint to same view for vertical centering
-
attrs['app:layout_constraintBottom_toBottomOf'] = "@id/#{item[:value]}"
-
when 'center_horizontal_to_view'
-
# Add end constraint to same view for horizontal centering
-
attrs['app:layout_constraintEnd_toEndOf'] = "@id/#{item[:value]}"
-
end
-
end
-
end
-
-
# Add default constraints for ConstraintLayout if none specified
-
12
if is_constraint_layout
-
# Add default horizontal constraint (top-left) if no horizontal constraint specified
-
6
if !constraint_flags[:horizontal]
-
5
attrs['app:layout_constraintStart_toStartOf'] = 'parent'
-
end
-
-
# Add default vertical constraint (top) if no vertical constraint specified
-
6
if !constraint_flags[:vertical]
-
5
attrs['app:layout_constraintTop_toTopOf'] = 'parent'
-
end
-
end
-
-
# Combine gravity values if there are multiple
-
12
if gravity_values.any?
-
attrs['android:layout_gravity'] = gravity_values.join('|')
-
end
-
-
12
attrs
-
end
-
-
# Process LinearLayout orientation
-
1
def process_orientation(view_class, json_element)
-
8
attrs = {}
-
-
8
if view_class == 'LinearLayout' && json_element['orientation']
-
1
attrs['android:orientation'] = json_element['orientation']
-
7
elsif view_class == 'LinearLayout'
-
# Default to vertical if not specified
-
1
attrs['android:orientation'] = 'vertical'
-
end
-
-
8
attrs
-
end
-
-
end
-
end
-
#!/usr/bin/env ruby
-
-
1
module XmlGenerator
-
1
module Mappers
-
1
class DimensionMapper
-
1
def map_dimension(value)
-
# Handle nil or empty string
-
47
return 'wrap_content' if value.nil? || value.to_s.empty?
-
-
45
case value
-
when 'matchParent', 'match_parent'
-
13
'match_parent'
-
when 'wrapContent', 'wrap_content'
-
16
'wrap_content'
-
when Integer, Float
-
7
"#{value.to_i}dp"
-
when /^\d+$/
-
1
"#{value}dp"
-
when /^\d+\.\d+$/
-
1
"#{value.to_f.to_i}dp"
-
when /^\d+dp$/
-
3
value
-
when /^\d+%$/
-
1
"0dp" # Will need layout_weight
-
else
-
3
value.to_s.empty? ? 'wrap_content' : value.to_s
-
end
-
end
-
-
1
def convert_dimension(value)
-
24
case value
-
when Integer, Float
-
19
"#{value.to_i}dp"
-
when String
-
2
if value.match?(/^\d+$/)
-
1
"#{value}dp"
-
else
-
1
value
-
end
-
when Array
-
# Use first value for now
-
2
convert_dimension(value.first || 0)
-
else
-
1
value.to_s
-
end
-
end
-
end
-
end
-
end
-
#!/usr/bin/env ruby
-
-
1
module XmlGenerator
-
1
module Mappers
-
1
class InputMapper
-
1
def map_input_attributes(key, value)
-
45
case key
-
# Input attributes
-
when 'inputType'
-
6
return { namespace: 'android', name: 'inputType', value: map_input_type(value) }
-
when 'placeholder'
-
1
return { namespace: 'android', name: 'hint', value: value }
-
when 'editable'
-
1
return { namespace: 'android', name: 'editable', value: value.to_s }
-
when 'singleLine'
-
1
return { namespace: 'android', name: 'singleLine', value: value.to_s }
-
when 'maxLength'
-
1
return { namespace: 'android', name: 'maxLength', value: value.to_s }
-
-
# Switch/Checkbox
-
when 'checked', 'isChecked'
-
3
return { namespace: 'android', name: 'checked', value: process_checked_value(value) }
-
-
# SelectBox/Spinner
-
when 'selectedItem'
-
1
return { namespace: 'app', name: 'selectedValue', value: value }
-
when 'entries', 'items'
-
2
if value.is_a?(Array)
-
2
return { namespace: 'app', name: 'items', value: value.join('|') }
-
else
-
return { namespace: 'app', name: 'items', value: value }
-
end
-
when 'selectItemType'
-
return { namespace: 'tools', name: 'selectItemType', value: value }
-
when 'hintColor'
-
# Process color value through ResourceResolver
-
1
color_value = KjuiTools::Xml::Helpers::ResourceResolver.process_color(value)
-
1
return { namespace: 'app', name: 'hintColor', value: color_value }
-
when 'prompt'
-
1
return { namespace: 'app', name: 'placeholder', value: value }
-
-
# Date picker attributes
-
when 'datePickerMode', 'datePickerStyle'
-
1
return { namespace: 'app', name: 'datePickerMode', value: value }
-
when 'dateFormat'
-
1
return { namespace: 'app', name: 'dateFormat', value: value }
-
when 'minDate', 'minimumDate'
-
1
return { namespace: 'app', name: 'minDate', value: value }
-
when 'maxDate', 'maximumDate'
-
1
return { namespace: 'app', name: 'maxDate', value: value }
-
-
# Progress/Slider
-
when 'progress'
-
1
return { namespace: 'android', name: 'progress', value: value.to_s }
-
when 'max', 'maxValue', 'maximumValue'
-
1
return { namespace: 'android', name: 'max', value: value.to_f.to_i.to_s }
-
when 'min', 'minValue', 'minimumValue'
-
1
return { namespace: 'android', name: 'min', value: value.to_f.to_i.to_s }
-
when 'value'
-
# For Slider, value maps to progress
-
2
return { namespace: 'android', name: 'progress', value: process_binding_value(value) }
-
when 'onValueChange'
-
1
return nil # Handled in code generation
-
-
# Events (will be handled in binding)
-
when 'onClick', 'onclick'
-
1
return { namespace: 'android', name: 'onClick', value: value }
-
when 'onTextChanged'
-
1
return nil # Handled in code
-
end
-
-
nil
-
end
-
-
1
private
-
-
1
def map_input_type(value)
-
input_type_map = {
-
6
'text' => 'text',
-
'number' => 'number',
-
'phone' => 'phone',
-
'email' => 'textEmailAddress',
-
'password' => 'textPassword',
-
'multiline' => 'textMultiLine'
-
}
-
6
input_type_map[value] || value
-
end
-
-
1
def process_checked_value(value)
-
3
if value.is_a?(String) && value.start_with?('@{')
-
1
value
-
else
-
2
value.to_s
-
end
-
end
-
-
1
def process_binding_value(value)
-
2
if value.is_a?(String) && value.start_with?('@{')
-
1
value
-
else
-
1
value.to_s
-
end
-
end
-
end
-
end
-
end
-
#!/usr/bin/env ruby
-
-
1
module XmlGenerator
-
1
module Mappers
-
1
class LayoutMapper
-
1
def initialize(dimension_mapper)
-
105
@dimension_mapper = dimension_mapper
-
end
-
-
1
def map_layout_attributes(key, value, component_type, parent_type)
-
44
case key
-
# Dimension attributes
-
when 'width'
-
2
return { namespace: 'android', name: 'layout_width', value: @dimension_mapper.map_dimension(value) }
-
when 'height'
-
2
return { namespace: 'android', name: 'layout_height', value: @dimension_mapper.map_dimension(value) }
-
-
# Padding attributes
-
when 'padding', 'paddings'
-
4
if value.is_a?(Array)
-
1
return { namespace: 'android', name: 'padding', value: @dimension_mapper.convert_dimension(value.first || 0) }
-
else
-
3
return { namespace: 'android', name: 'padding', value: @dimension_mapper.convert_dimension(value) }
-
end
-
when 'topPadding', 'paddingTop'
-
1
return { namespace: 'android', name: 'paddingTop', value: @dimension_mapper.convert_dimension(value) }
-
when 'bottomPadding', 'paddingBottom'
-
1
return { namespace: 'android', name: 'paddingBottom', value: @dimension_mapper.convert_dimension(value) }
-
when 'leftPadding', 'paddingLeft', 'startPadding', 'paddingStart'
-
1
return { namespace: 'android', name: 'paddingStart', value: @dimension_mapper.convert_dimension(value) }
-
when 'rightPadding', 'paddingRight', 'endPadding', 'paddingEnd'
-
1
return { namespace: 'android', name: 'paddingEnd', value: @dimension_mapper.convert_dimension(value) }
-
-
# Margin attributes
-
when 'margin'
-
2
if value.is_a?(Array)
-
1
return { namespace: 'android', name: 'layout_margin', value: @dimension_mapper.convert_dimension(value.first || 0) }
-
else
-
1
return { namespace: 'android', name: 'layout_margin', value: @dimension_mapper.convert_dimension(value) }
-
end
-
when 'topMargin', 'marginTop'
-
1
return { namespace: 'android', name: 'layout_marginTop', value: @dimension_mapper.convert_dimension(value) }
-
when 'bottomMargin', 'marginBottom'
-
1
return { namespace: 'android', name: 'layout_marginBottom', value: @dimension_mapper.convert_dimension(value) }
-
when 'leftMargin', 'marginLeft', 'startMargin', 'marginStart'
-
1
return { namespace: 'android', name: 'layout_marginStart', value: @dimension_mapper.convert_dimension(value) }
-
when 'rightMargin', 'marginRight', 'endMargin', 'marginEnd'
-
1
return { namespace: 'android', name: 'layout_marginEnd', value: @dimension_mapper.convert_dimension(value) }
-
-
# Layout specific
-
when 'orientation'
-
1
return { namespace: 'android', name: 'orientation', value: value }
-
when 'weight'
-
1
return { namespace: 'android', name: 'layout_weight', value: value.to_s }
-
when 'gravity'
-
2
return { namespace: 'android', name: 'gravity', value: map_gravity(value) }
-
when 'layout_gravity'
-
1
return { namespace: 'android', name: 'layout_gravity', value: map_gravity(value) }
-
end
-
-
nil
-
end
-
-
1
def map_alignment_attributes(key, value, parent_type)
-
# Check if parent is ConstraintLayout
-
38
is_constraint_layout = parent_type&.include?('ConstraintLayout')
-
-
38
case key
-
when 'alignTop'
-
3
if parent_type == 'LinearLayout'
-
1
return { namespace: 'android', name: 'layout_gravity', value: 'top' } if value
-
2
elsif is_constraint_layout
-
1
return { namespace: 'app', name: 'layout_constraintTop_toTopOf', value: 'parent' } if value
-
else
-
1
return { namespace: 'android', name: 'layout_alignParentTop', value: value.to_s }
-
end
-
when 'alignBottom'
-
3
if parent_type == 'LinearLayout'
-
1
return { namespace: 'android', name: 'layout_gravity', value: 'bottom' } if value
-
2
elsif is_constraint_layout
-
2
return { namespace: 'app', name: 'layout_constraintBottom_toBottomOf', value: 'parent' } if value
-
else
-
return { namespace: 'android', name: 'layout_alignParentBottom', value: value.to_s }
-
end
-
when 'alignLeft', 'alignStart'
-
2
if parent_type == 'LinearLayout'
-
1
return { namespace: 'android', name: 'layout_gravity', value: 'start' } if value
-
1
elsif is_constraint_layout
-
1
return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: 'parent' } if value
-
else
-
return { namespace: 'android', name: 'layout_alignParentStart', value: value.to_s }
-
end
-
when 'alignRight', 'alignEnd'
-
2
if parent_type == 'LinearLayout'
-
return { namespace: 'android', name: 'layout_gravity', value: 'end' } if value
-
2
elsif is_constraint_layout
-
2
return { namespace: 'app', name: 'layout_constraintEnd_toEndOf', value: 'parent' } if value
-
else
-
return { namespace: 'android', name: 'layout_alignParentEnd', value: value.to_s }
-
end
-
when 'centerHorizontal'
-
2
if parent_type == 'LinearLayout'
-
1
return { namespace: 'android', name: 'layout_gravity', value: 'center_horizontal' } if value
-
1
elsif is_constraint_layout
-
# For horizontal centering in ConstraintLayout, we need both start and end constraints
-
# This will be handled specially
-
return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: 'parent', extra: 'center_horizontal' } if value
-
else
-
1
return { namespace: 'android', name: 'layout_centerHorizontal', value: value.to_s }
-
end
-
when 'centerVertical'
-
if parent_type == 'LinearLayout'
-
return { namespace: 'android', name: 'layout_gravity', value: 'center_vertical' } if value
-
elsif is_constraint_layout
-
# For vertical centering in ConstraintLayout, we need both top and bottom constraints
-
return { namespace: 'app', name: 'layout_constraintTop_toTopOf', value: 'parent', extra: 'center_vertical' } if value
-
else
-
return { namespace: 'android', name: 'layout_centerVertical', value: value.to_s }
-
end
-
when 'centerInParent'
-
1
if parent_type == 'LinearLayout'
-
1
return { namespace: 'android', name: 'layout_gravity', value: 'center' } if value
-
elsif is_constraint_layout
-
# For centering in ConstraintLayout, we need all four constraints
-
return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: 'parent', extra: 'center_in_parent' } if value
-
else
-
return { namespace: 'android', name: 'layout_centerInParent', value: value.to_s }
-
end
-
-
# Relative positioning - align to edges of another view
-
when 'alignTopView'
-
if is_constraint_layout
-
return { namespace: 'app', name: 'layout_constraintTop_toTopOf', value: "@id/#{value}" }
-
else
-
return { namespace: 'android', name: 'layout_alignTop', value: "@id/#{value}" }
-
end
-
when 'alignBottomView'
-
if is_constraint_layout
-
return { namespace: 'app', name: 'layout_constraintBottom_toBottomOf', value: "@id/#{value}" }
-
else
-
return { namespace: 'android', name: 'layout_alignBottom', value: "@id/#{value}" }
-
end
-
when 'alignLeftView'
-
if is_constraint_layout
-
return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: "@id/#{value}" }
-
else
-
return { namespace: 'android', name: 'layout_alignStart', value: "@id/#{value}" }
-
end
-
when 'alignRightView'
-
if is_constraint_layout
-
return { namespace: 'app', name: 'layout_constraintEnd_toEndOf', value: "@id/#{value}" }
-
else
-
return { namespace: 'android', name: 'layout_alignEnd', value: "@id/#{value}" }
-
end
-
-
# Center alignment with another view (ConstraintLayout only)
-
when 'alignCenterVerticalView'
-
if is_constraint_layout
-
# To center vertically with another view, constrain both top and bottom to that view
-
return { namespace: 'app', name: 'layout_constraintTop_toTopOf', value: "@id/#{value}", extra: 'center_vertical_to_view' }
-
else
-
puts "Warning: alignCenterVerticalView requires ConstraintLayout"
-
return nil
-
end
-
when 'alignCenterHorizontalView'
-
if is_constraint_layout
-
# To center horizontally with another view, constrain both start and end to that view
-
return { namespace: 'app', name: 'layout_constraintStart_toStartOf', value: "@id/#{value}", extra: 'center_horizontal_to_view' }
-
else
-
puts "Warning: alignCenterHorizontalView requires ConstraintLayout"
-
return nil
-
end
-
-
# Position relative to another view (outside edges)
-
when 'alignTopOfView', 'above'
-
2
if is_constraint_layout
-
1
return { namespace: 'app', name: 'layout_constraintBottom_toTopOf', value: "@id/#{value}" }
-
else
-
1
return { namespace: 'android', name: 'layout_above', value: "@id/#{value}" }
-
end
-
when 'alignBottomOfView', 'below'
-
2
if is_constraint_layout
-
1
return { namespace: 'app', name: 'layout_constraintTop_toBottomOf', value: "@id/#{value}" }
-
else
-
1
return { namespace: 'android', name: 'layout_below', value: "@id/#{value}" }
-
end
-
when 'alignLeftOfView', 'toLeftOf'
-
1
if is_constraint_layout
-
return { namespace: 'app', name: 'layout_constraintEnd_toStartOf', value: "@id/#{value}" }
-
else
-
1
return { namespace: 'android', name: 'layout_toStartOf', value: "@id/#{value}" }
-
end
-
when 'alignRightOfView', 'toRightOf'
-
1
if is_constraint_layout
-
return { namespace: 'app', name: 'layout_constraintStart_toEndOf', value: "@id/#{value}" }
-
else
-
1
return { namespace: 'android', name: 'layout_toEndOf', value: "@id/#{value}" }
-
end
-
end
-
-
nil
-
end
-
-
1
private
-
-
1
def map_gravity(value)
-
3
if value.is_a?(Array)
-
1
value.join('|')
-
else
-
2
case value
-
when 'center'
-
2
'center'
-
when 'left', 'start'
-
'start'
-
when 'right', 'end'
-
'end'
-
when 'top'
-
'top'
-
when 'bottom'
-
'bottom'
-
else
-
value
-
end
-
end
-
end
-
end
-
end
-
end
-
#!/usr/bin/env ruby
-
-
1
require_relative '../resource_resolver'
-
-
1
module XmlGenerator
-
1
module Mappers
-
1
class StyleMapper
-
1
def initialize(text_mapper, drawable_generator = nil)
-
103
@text_mapper = text_mapper
-
103
@drawable_generator = drawable_generator
-
end
-
-
1
def map_style_attributes(key, value, json_element = nil, component_type = nil)
-
54
case key
-
# Background and appearance
-
when 'background', 'backgroundColor'
-
# Check if we need to generate a drawable
-
3
if @drawable_generator && json_element && needs_drawable?(json_element, component_type)
-
drawable_name = @drawable_generator.get_background_drawable(json_element, component_type)
-
if drawable_name
-
return { namespace: 'android', name: 'background', value: "@drawable/#{drawable_name}" }
-
end
-
end
-
3
return { namespace: 'android', name: 'background', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
-
when 'cornerRadius'
-
# Handled in drawable generation
-
return nil if @drawable_generator
-
return { namespace: 'tools', name: 'cornerRadius', value: convert_dimension(value) }
-
when 'borderWidth', 'strokeWidth'
-
# Handled in drawable generation
-
return nil if @drawable_generator
-
return { namespace: 'tools', name: 'strokeWidth', value: convert_dimension(value) }
-
when 'borderColor', 'strokeColor'
-
# Handled in drawable generation
-
return nil if @drawable_generator
-
return { namespace: 'tools', name: 'strokeColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
-
when 'borderStyle'
-
# Handled in drawable generation if available
-
return nil if @drawable_generator
-
return { namespace: 'tools', name: 'borderStyle', value: value }
-
when 'opacity', 'alpha'
-
2
return { namespace: 'android', name: 'alpha', value: value.to_f.to_s }
-
when 'visibility'
-
3
return { namespace: 'android', name: 'visibility', value: map_visibility(value) }
-
when 'enabled'
-
1
return { namespace: 'android', name: 'enabled', value: value.to_s }
-
when 'clickable'
-
1
return { namespace: 'android', name: 'clickable', value: value.to_s }
-
when 'focusable'
-
1
return { namespace: 'android', name: 'focusable', value: value.to_s }
-
-
# Image attributes
-
when 'src', 'source', 'image'
-
3
return map_image_source(value, component_type)
-
when 'url'
-
# For NetworkImageView and CircleImageView
-
1
return { namespace: 'app', name: 'url', value: value }
-
when 'placeholderImage'
-
# For NetworkImageView placeholder image
-
1
if value.start_with?('@drawable/')
-
return { namespace: 'app', name: 'placeholderImage', value: value }
-
else
-
1
resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
-
1
return { namespace: 'app', name: 'placeholderImage', value: "@drawable/#{resource_name}" }
-
end
-
when 'placeholder'
-
# For NetworkImageView/CircleImageView, use placeholderImage
-
1
if component_type && ['NetworkImage', 'CircleImage'].include?(component_type)
-
1
if value.start_with?('@drawable/')
-
return { namespace: 'app', name: 'placeholderImage', value: value }
-
else
-
1
resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
-
1
return { namespace: 'app', name: 'placeholderImage', value: "@drawable/#{resource_name}" }
-
end
-
end
-
# For other components, let input_mapper handle it as hint
-
return nil
-
when 'errorImage', 'failureImage'
-
# For NetworkImageView error image
-
1
if value.start_with?('@drawable/')
-
return { namespace: 'app', name: 'errorImage', value: value }
-
else
-
1
resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
-
1
return { namespace: 'app', name: 'errorImage', value: "@drawable/#{resource_name}" }
-
end
-
when 'defaultImage', 'fallbackImage'
-
# For NetworkImageView default/fallback image
-
if value.start_with?('@drawable/')
-
return { namespace: 'app', name: 'defaultImage', value: value }
-
else
-
resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
-
return { namespace: 'app', name: 'defaultImage', value: "@drawable/#{resource_name}" }
-
end
-
when 'crossfadeEnabled', 'crossfade'
-
1
return { namespace: 'app', name: 'crossfadeEnabled', value: value.to_s }
-
when 'cacheEnabled'
-
1
return { namespace: 'app', name: 'cacheEnabled', value: value.to_s }
-
when 'scaleType'
-
2
return { namespace: 'android', name: 'scaleType', value: map_scale_type(value) }
-
when 'tint'
-
1
return { namespace: 'android', name: 'tint', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
-
-
# Blur attributes
-
when 'blurRadius'
-
1
return { namespace: 'app', name: 'blurRadius', value: value.to_f.to_s }
-
when 'blurOverlayColor'
-
1
return { namespace: 'app', name: 'blurOverlayColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
-
when 'downsampleFactor'
-
1
return { namespace: 'app', name: 'downsampleFactor', value: value.to_f.to_s }
-
when 'blurEnabled'
-
1
return { namespace: 'app', name: 'blurEnabled', value: value.to_s }
-
-
# Gradient attributes
-
when 'gradientStartColor', 'startColor'
-
1
return { namespace: 'app', name: 'gradientStartColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
-
when 'gradientEndColor', 'endColor'
-
1
return { namespace: 'app', name: 'gradientEndColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
-
when 'gradientCenterColor', 'centerColor'
-
return { namespace: 'app', name: 'gradientCenterColor', value: KjuiTools::Xml::Helpers::ResourceResolver.process_color(value) }
-
when 'gradientColors', 'colors'
-
# Handle array of colors - don't process through ResourceResolver
-
# gradientColors expects raw color values separated by |
-
1
if value.is_a?(Array)
-
4
colors_string = value.map { |c| normalize_color_for_gradient(c) }.join('|')
-
1
return { namespace: 'app', name: 'gradientColors', value: colors_string }
-
else
-
return { namespace: 'app', name: 'gradientColors', value: value }
-
end
-
when 'gradientDirection', 'direction'
-
2
return { namespace: 'app', name: 'gradientOrientation', value: map_gradient_direction(value) }
-
when 'gradientAngle', 'angle'
-
1
return { namespace: 'app', name: 'gradientAngle', value: value.to_s }
-
when 'gradientType'
-
2
return { namespace: 'app', name: 'gradientType', value: map_gradient_type(value) }
-
when 'gradientRadius'
-
1
return { namespace: 'app', name: 'gradientRadius', value: value.to_f.to_s }
-
when 'gradientCenterX'
-
return { namespace: 'app', name: 'gradientCenterX', value: value.to_f.to_s }
-
when 'gradientCenterY'
-
return { namespace: 'app', name: 'gradientCenterY', value: value.to_f.to_s }
-
-
# SafeAreaView attributes
-
when 'safeAreaInsetPositions', 'insetPositions'
-
# Handle array of positions
-
1
if value.is_a?(Array)
-
1
positions_string = value.join('|')
-
1
return { namespace: 'app', name: 'safeAreaInsetPositions', value: positions_string }
-
else
-
return { namespace: 'app', name: 'safeAreaInsetPositions', value: value }
-
end
-
when 'contentInsetAdjustmentBehavior'
-
return { namespace: 'app', name: 'contentInsetAdjustmentBehavior', value: value.to_s }
-
when 'applyTopInset'
-
1
return { namespace: 'app', name: 'applyTopInset', value: value.to_s }
-
when 'applyBottomInset'
-
1
return { namespace: 'app', name: 'applyBottomInset', value: value.to_s }
-
when 'applyLeftInset'
-
return { namespace: 'app', name: 'applyLeftInset', value: value.to_s }
-
when 'applyRightInset'
-
return { namespace: 'app', name: 'applyRightInset', value: value.to_s }
-
when 'applyStartInset'
-
return { namespace: 'app', name: 'applyStartInset', value: value.to_s }
-
when 'applyEndInset'
-
return { namespace: 'app', name: 'applyEndInset', value: value.to_s }
-
-
# State-specific attributes (handled by drawable generation)
-
when 'disabledBackground', 'tapBackground', 'pressedBackground',
-
'selectedBackground', 'focusedBackground', 'checkedBackground',
-
'rippleColor', 'rippleBorderless'
-
# These are handled by drawable generation
-
return nil if @drawable_generator
-
return { namespace: 'tools', name: key, value: value.to_s }
-
end
-
-
nil
-
end
-
-
1
private
-
-
1
def needs_drawable?(json_element, component_type)
-
return false unless json_element
-
-
# Check if any drawable-related attributes exist
-
json_element['cornerRadius'] ||
-
json_element['borderWidth'] ||
-
json_element['borderColor'] ||
-
json_element['gradient'] ||
-
json_element['disabledBackground'] ||
-
json_element['tapBackground'] ||
-
json_element['pressedBackground'] ||
-
json_element['selectedBackground'] ||
-
json_element['focusedBackground'] ||
-
json_element['checkedBackground'] ||
-
json_element['onClick'] ||
-
json_element['onclick'] ||
-
json_element['rippleColor'] ||
-
['Button', 'ImageButton', 'Card', 'ListItem'].include?(component_type)
-
end
-
-
1
def convert_dimension(value)
-
case value
-
when Integer, Float
-
"#{value.to_i}dp"
-
when String
-
if value.match?(/^\d+$/)
-
"#{value}dp"
-
else
-
value
-
end
-
else
-
value.to_s
-
end
-
end
-
-
1
def map_visibility(value)
-
3
case value
-
when true, 'visible'
-
1
'visible'
-
when false, 'gone'
-
1
'gone'
-
when 'invisible'
-
1
'invisible'
-
else
-
value
-
end
-
end
-
-
1
def map_image_source(value, component_type = nil)
-
# For NetworkImageView and CircleImageView, map src to url attribute
-
3
if component_type && ['NetworkImage', 'CircleImage'].include?(component_type)
-
1
return { namespace: 'app', name: 'url', value: value }
-
end
-
-
2
if value.start_with?('http')
-
# Network image - use tools for documentation
-
1
{ namespace: 'tools', name: 'src', value: value }
-
else
-
# Local resource
-
1
resource_name = value.gsub(/\.\w+$/, '').downcase.gsub(/[^a-z0-9_]/, '_')
-
1
{ namespace: 'android', name: 'src', value: "@drawable/#{resource_name}" }
-
end
-
end
-
-
1
def map_scale_type(value)
-
scale_type_map = {
-
2
'fill' => 'centerCrop',
-
'fit' => 'fitCenter',
-
'stretch' => 'fitXY',
-
'center' => 'center'
-
}
-
2
scale_type_map[value] || value
-
end
-
-
1
def map_gradient_direction(value)
-
direction_map = {
-
2
'vertical' => 'top_bottom',
-
'horizontal' => 'left_right',
-
'diagonal' => 'tl_br',
-
'diagonal_reverse' => 'tr_bl',
-
'topBottom' => 'top_bottom',
-
'bottomTop' => 'bottom_top',
-
'leftRight' => 'left_right',
-
'rightLeft' => 'right_left',
-
'rightToLeft' => 'right_left',
-
'leftToRight' => 'left_right',
-
'topToBottom' => 'top_bottom',
-
'bottomToTop' => 'bottom_top',
-
'tlBr' => 'tl_br',
-
'trBl' => 'tr_bl',
-
'blTr' => 'bl_tr',
-
'brTl' => 'br_tl'
-
}
-
2
direction_map[value] || 'top_bottom' # Default to top_bottom for unknown values
-
end
-
-
1
def map_gradient_type(value)
-
type_map = {
-
2
'linear' => 'linear',
-
'radial' => 'radial',
-
'sweep' => 'sweep',
-
'angular' => 'sweep'
-
}
-
2
type_map[value] || 'linear'
-
end
-
-
1
def normalize_color_for_gradient(color)
-
3
return '#00000000' if color == 'clear' || color == 'transparent'
-
-
# Ensure hex format for colors
-
3
if color.match?(/^#?[A-Fa-f0-9]{6,8}$/)
-
3
color.start_with?('#') ? color : "##{color}"
-
else
-
# Return as-is for named colors or other formats
-
color
-
end
-
end
-
end
-
end
-
end
-
#!/usr/bin/env ruby
-
-
1
require_relative '../resource_resolver'
-
-
1
module XmlGenerator
-
1
module Mappers
-
1
class TextMapper
-
1
def initialize(string_resource_manager = nil)
-
141
@string_resource_manager = string_resource_manager
-
end
-
-
1
def map_text_attributes(key, value, component_type)
-
54
case key
-
when 'text'
-
5
return { namespace: 'android', name: 'text', value: process_text_value(value) }
-
when 'hint'
-
2
hint_value = process_hint_value(value)
-
2
return { namespace: 'android', name: 'hint', value: hint_value }
-
when 'fontSize', 'textSize'
-
4
return { namespace: 'android', name: 'textSize', value: convert_text_size(value) }
-
when 'fontColor', 'textColor'
-
2
return { namespace: 'android', name: 'textColor', value: convert_color(value) }
-
when 'color'
-
# Generic color attribute - determine based on component type
-
3
if ['Label', 'Text', 'TextView', 'Button'].include?(component_type)
-
2
return { namespace: 'android', name: 'textColor', value: convert_color(value) }
-
else
-
1
return { namespace: 'android', name: 'tint', value: convert_color(value) }
-
end
-
when 'font'
-
# Check if it's a font weight/style or a font file name
-
7
if ['bold', 'italic', 'normal', 'bold_italic'].include?(value.to_s.downcase)
-
# It's a text style
-
2
return map_font_weight(value)
-
5
elsif ['Label', 'Text', 'TextView', 'TextField', 'SecureField', 'Button'].include?(component_type)
-
# It's a font file name for Kjui views
-
# Add .ttf extension if not present
-
4
font_file = value.to_s
-
4
font_file += '.ttf' unless font_file.end_with?('.ttf', '.otf')
-
4
return { namespace: 'app', name: 'kjui_font_name', value: font_file }
-
else
-
# For non-Kjui views, use as fontFamily
-
1
return { namespace: 'android', name: 'fontFamily', value: value }
-
end
-
when 'fontFamily'
-
# fontFamily is always treated as a font file name
-
2
if ['Label', 'Text', 'TextView', 'TextField', 'SecureField', 'Button'].include?(component_type)
-
1
font_file = value.to_s
-
1
font_file += '.ttf' unless font_file.end_with?('.ttf', '.otf')
-
1
return { namespace: 'app', name: 'kjui_font_name', value: font_file }
-
else
-
1
return { namespace: 'android', name: 'fontFamily', value: value }
-
end
-
when 'fontWeight'
-
6
return map_font_weight(value)
-
when 'fontStyle'
-
return { namespace: 'android', name: 'textStyle', value: value }
-
when 'textAlign', 'textAlignment'
-
5
return { namespace: 'android', name: 'textAlignment', value: map_text_alignment(value) }
-
when 'maxLines'
-
1
return { namespace: 'android', name: 'maxLines', value: value.to_s }
-
when 'ellipsize'
-
1
return { namespace: 'android', name: 'ellipsize', value: value }
-
end
-
-
nil
-
end
-
-
1
private
-
-
1
def process_hint_value(value)
-
# Handle data binding
-
2
if value.is_a?(String) && value.start_with?('@{')
-
1
return value
-
end
-
-
# Convert value to string
-
1
text = value.to_s
-
-
# Use ResourceResolver to check for string resources
-
1
KjuiTools::Xml::Helpers::ResourceResolver.process_text(text)
-
end
-
-
1
def process_text_value(value)
-
# Handle data binding
-
5
if value.is_a?(String) && value.start_with?('@{')
-
1
return value
-
end
-
-
# Convert value to string
-
4
text = value.to_s
-
-
# Use ResourceResolver to check for string resources
-
4
KjuiTools::Xml::Helpers::ResourceResolver.process_text(text)
-
end
-
-
1
def convert_text_size(value)
-
4
case value
-
when Integer, Float
-
2
"#{value}sp"
-
when String
-
2
if value.match?(/^\d+$/)
-
1
"#{value}sp"
-
else
-
1
value
-
end
-
else
-
"14sp"
-
end
-
end
-
-
1
def convert_color(value)
-
5
return nil if value.nil?
-
-
# Handle special color values
-
5
if value.is_a?(String)
-
5
if value == 'clear' || value == 'transparent'
-
return '#00000000'
-
end
-
-
# Use ResourceResolver to check for color resources
-
5
return KjuiTools::Xml::Helpers::ResourceResolver.process_color(value)
-
else
-
value.to_s
-
end
-
end
-
-
1
def map_font_weight(value)
-
8
case value.to_s.downcase
-
when 'bold'
-
2
{ namespace: 'android', name: 'textStyle', value: 'bold' }
-
when 'italic'
-
2
{ namespace: 'android', name: 'textStyle', value: 'italic' }
-
when 'bold_italic', 'bolditalic'
-
1
{ namespace: 'android', name: 'textStyle', value: 'bold|italic' }
-
when 'normal', 'regular', 'light', 'thin'
-
1
{ namespace: 'android', name: 'textStyle', value: 'normal' }
-
when 'medium', 'semibold', 'heavy', 'black'
-
# Medium and similar weights map to bold in Android
-
1
{ namespace: 'android', name: 'textStyle', value: 'bold' }
-
else
-
# Default to normal for unknown values
-
1
{ namespace: 'android', name: 'textStyle', value: 'normal' }
-
end
-
end
-
-
1
def map_text_alignment(value)
-
5
case value
-
when 'left', 'start'
-
2
'textStart'
-
when 'right', 'end'
-
2
'textEnd'
-
when 'center'
-
1
'center'
-
else
-
value
-
end
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
require 'json'
-
1
require_relative '../../core/logger'
-
-
1
module KjuiTools
-
1
module Xml
-
1
module Helpers
-
1
class ResourceResolver
-
1
class << self
-
# Load resources lazily
-
1
def strings_data
-
6
@strings_data ||= load_strings_data
-
end
-
-
1
def colors_data
-
112
@colors_data ||= load_colors_data
-
end
-
-
1
def defined_colors_data
-
56
@defined_colors_data ||= load_defined_colors_data
-
end
-
-
# Clear cache (useful when resources change)
-
1
def clear_cache
-
19
@strings_data = nil
-
19
@colors_data = nil
-
19
@defined_colors_data = nil
-
end
-
-
# Process text value - returns @string/key or original text
-
1
def process_text(text)
-
10
return text if text.nil? || text.empty?
-
-
# Skip data binding expressions
-
8
return text if text.start_with?('@{') || text.start_with?('${')
-
-
# Find string key
-
6
string_key = find_string_key(text)
-
6
if string_key
-
"@string/#{string_key}"
-
else
-
# Return original text wrapped in quotes for XML
-
6
"\"#{text}\""
-
end
-
end
-
-
# Process color value - returns @color/key or hex color
-
1
def process_color(color)
-
61
return color if color.nil? || color.empty?
-
-
# Skip data binding expressions
-
59
return color if color.start_with?('@{') || color.start_with?('${')
-
-
# Skip if already a resource reference
-
57
return color if color.start_with?('@')
-
-
# Find color key
-
56
color_key = find_color_key(color)
-
56
if color_key
-
"@color/#{color_key}"
-
else
-
# Return hex color with # prefix
-
56
if color.match?(/^#?[A-Fa-f0-9]{6,8}$/)
-
56
color.start_with?('#') ? color : "##{color}"
-
else
-
color
-
end
-
end
-
end
-
-
1
private
-
-
1
def load_strings_data
-
3
strings_file = find_strings_json
-
3
return {} unless strings_file && File.exist?(strings_file)
-
-
begin
-
data = JSON.parse(File.read(strings_file))
-
# Flatten the nested structure (file -> key -> value)
-
flattened = {}
-
data.each do |file_prefix, file_strings|
-
next unless file_strings.is_a?(Hash)
-
file_strings.each do |key, value|
-
full_key = "#{file_prefix}_#{key}"
-
flattened[full_key] = value
-
end
-
end
-
flattened
-
rescue JSON::ParserError => e
-
Core::Logger.warn "Failed to parse strings.json: #{e.message}"
-
{}
-
end
-
end
-
-
1
def load_colors_data
-
5
colors_file = find_colors_json
-
5
return {} unless colors_file && File.exist?(colors_file)
-
-
begin
-
JSON.parse(File.read(colors_file))
-
rescue JSON::ParserError => e
-
Core::Logger.warn "Failed to parse colors.json: #{e.message}"
-
{}
-
end
-
end
-
-
1
def load_defined_colors_data
-
5
defined_colors_file = find_defined_colors_json
-
5
return {} unless defined_colors_file && File.exist?(defined_colors_file)
-
-
begin
-
JSON.parse(File.read(defined_colors_file))
-
rescue JSON::ParserError => e
-
Core::Logger.warn "Failed to parse defined_colors.json: #{e.message}"
-
{}
-
end
-
end
-
-
1
def find_strings_json
-
# Try common locations
-
3
paths = [
-
'src/main/assets/Layouts/Resources/strings.json',
-
'app/src/main/assets/Layouts/Resources/strings.json',
-
'sample-app/src/main/assets/Layouts/Resources/strings.json'
-
]
-
-
3
paths.each do |path|
-
9
full_path = File.expand_path(path)
-
9
return full_path if File.exist?(full_path)
-
end
-
-
nil
-
end
-
-
1
def find_colors_json
-
# Try common locations
-
5
paths = [
-
'src/main/assets/Layouts/Resources/colors.json',
-
'app/src/main/assets/Layouts/Resources/colors.json',
-
'sample-app/src/main/assets/Layouts/Resources/colors.json'
-
]
-
-
5
paths.each do |path|
-
15
full_path = File.expand_path(path)
-
15
return full_path if File.exist?(full_path)
-
end
-
-
nil
-
end
-
-
1
def find_defined_colors_json
-
# Try common locations
-
5
paths = [
-
'src/main/assets/Layouts/Resources/defined_colors.json',
-
'app/src/main/assets/Layouts/Resources/defined_colors.json',
-
'sample-app/src/main/assets/Layouts/Resources/defined_colors.json'
-
]
-
-
5
paths.each do |path|
-
15
full_path = File.expand_path(path)
-
15
return full_path if File.exist?(full_path)
-
end
-
-
nil
-
end
-
-
1
def find_string_key(text)
-
6
strings_data.find { |key, value| value == text }&.first
-
end
-
-
1
def find_color_key(color)
-
# If the color itself is a key in colors.json, return it
-
56
if colors_data.key?(color)
-
return color
-
end
-
-
# If the color itself is in defined_colors.json, return it
-
56
if defined_colors_data.key?(color)
-
return color
-
end
-
-
# Otherwise, normalize and search for hex values
-
56
normalized_color = normalize_color(color)
-
-
# Check colors.json for hex values
-
56
if colors_data.any? && normalized_color
-
found = colors_data.find { |key, value| normalize_color(value) == normalized_color }
-
return found.first if found
-
end
-
-
nil
-
end
-
-
1
def normalize_color(color)
-
61
return nil if color.nil?
-
-
# If it's a hex color, normalize it
-
60
if color.match?(/^#?[A-Fa-f0-9]{6,8}$/)
-
58
hex = color.gsub('#', '').upcase
-
# Convert 3-digit to 6-digit
-
58
if hex.length == 3
-
hex = hex.chars.map { |c| c * 2 }.join
-
end
-
58
"##{hex}"
-
else
-
2
color
-
end
-
end
-
end
-
end
-
end
-
end
-
end
-
1
require 'nokogiri'
-
1
require 'fileutils'
-
-
1
module XmlGenerator
-
1
module Resources
-
1
class StringResourceManager
-
1
def initialize(project_root)
-
24
@project_root = project_root
-
24
@strings_file_path = find_strings_file
-
24
@strings_cache = {}
-
24
@new_strings = {}
-
24
load_existing_strings
-
end
-
-
# Get or create a string resource reference
-
1
def get_string_resource(text)
-
return nil if text.nil? || text.empty?
-
-
# Check if it's already a resource reference
-
return text if text.start_with?('@string/')
-
-
# Check if it's a data binding expression
-
return text if text.start_with?('@{')
-
-
# Check if text is too short or just numbers
-
return text if text.length < 2 || text.match?(/^\d+$/)
-
-
# Check existing strings
-
existing_name = find_existing_string(text)
-
return "@string/#{existing_name}" if existing_name
-
-
# Check if we already created this string in this session
-
new_name = @new_strings.key(text)
-
return "@string/#{new_name}" if new_name
-
-
# Create new string resource
-
string_name = generate_string_name(text)
-
@new_strings[string_name] = text
-
"@string/#{string_name}"
-
end
-
-
# Save all new strings to strings.xml
-
1
def save_new_strings
-
3
return if @new_strings.empty?
-
-
ensure_strings_file_exists
-
-
# Load the XML file
-
doc = Nokogiri::XML(File.read(@strings_file_path)) do |config|
-
config.default_xml.noblanks
-
end
-
-
resources = doc.at_xpath('//resources')
-
-
# Add new strings
-
@new_strings.each do |name, value|
-
# Skip if already exists (double check)
-
next if doc.at_xpath("//string[@name='#{name}']")
-
-
# Create new string element
-
string_element = Nokogiri::XML::Node.new('string', doc)
-
string_element['name'] = name
-
-
# Process the value to handle line breaks properly
-
processed_value = escape_xml_text(value)
-
-
# Replace line breaks with \n for XML
-
processed_value = processed_value.gsub(/\r?\n/, '\n')
-
-
string_element.content = processed_value
-
-
# Add to resources
-
resources.add_child("\n ")
-
resources.add_child(string_element)
-
end
-
-
# Add final newline if there are children
-
if resources.children.any?
-
resources.add_child("\n")
-
end
-
-
# Save the file
-
File.write(@strings_file_path, doc.to_xml(
-
indent: 4,
-
indent_text: ' ',
-
save_with: Nokogiri::XML::Node::SaveOptions::FORMAT |
-
Nokogiri::XML::Node::SaveOptions::AS_XML
-
))
-
-
puts "✅ Added #{@new_strings.size} new strings to strings.xml"
-
-
# Add to cache for future lookups
-
@strings_cache.merge!(@new_strings)
-
@new_strings.clear
-
end
-
-
1
private
-
-
1
def find_strings_file
-
possible_paths = [
-
24
File.join(@project_root, 'src', 'main', 'res', 'values', 'strings.xml'),
-
File.join(@project_root, 'app', 'src', 'main', 'res', 'values', 'strings.xml'),
-
File.join(@project_root, 'sample-app', 'src', 'main', 'res', 'values', 'strings.xml')
-
]
-
-
94
possible_paths.find { |path| File.exist?(path) } || possible_paths.first
-
end
-
-
1
def ensure_strings_file_exists
-
return if File.exist?(@strings_file_path)
-
-
# Create directory if needed
-
FileUtils.mkdir_p(File.dirname(@strings_file_path))
-
-
# Create basic strings.xml
-
content = <<~XML
-
<?xml version="1.0" encoding="utf-8"?>
-
<resources>
-
<string name="app_name">App</string>
-
</resources>
-
XML
-
-
File.write(@strings_file_path, content)
-
end
-
-
1
def load_existing_strings
-
24
return unless File.exist?(@strings_file_path)
-
-
begin
-
1
doc = Nokogiri::XML(File.read(@strings_file_path))
-
-
# Load all existing strings into cache
-
1
doc.xpath('//string').each do |string_node|
-
1
name = string_node['name']
-
1
value = unescape_xml_text(string_node.text)
-
1
@strings_cache[name] = value if name && value
-
end
-
rescue => e
-
puts "Warning: Could not parse strings.xml: #{e.message}"
-
end
-
end
-
-
1
def find_existing_string(text)
-
# Exact match
-
@strings_cache.find { |name, value| value == text }&.first
-
end
-
-
1
def generate_string_name(text)
-
# Generate a meaningful name from the text
-
base_name = text.downcase
-
.gsub(/[^a-z0-9\s_-]/, '') # Remove special characters
-
.gsub(/-/, '_') # Replace hyphens with underscores
-
.gsub(/\s+/, '_') # Replace spaces with underscores
-
.gsub(/_+/, '_') # Remove duplicate underscores
-
.gsub(/^_|_$/, '') # Remove leading/trailing underscores
-
-
# Handle reserved words
-
reserved_words = ['default', 'public', 'private', 'protected', 'static',
-
'final', 'abstract', 'class', 'interface', 'enum',
-
'package', 'import', 'return', 'if', 'else', 'switch',
-
'case', 'break', 'continue', 'for', 'while', 'do',
-
'try', 'catch', 'finally', 'throw', 'throws', 'new',
-
'this', 'super', 'extends', 'implements', 'void',
-
'boolean', 'int', 'long', 'float', 'double', 'char',
-
'byte', 'short', 'true', 'false', 'null']
-
-
if reserved_words.include?(base_name)
-
base_name = "str_#{base_name}"
-
end
-
-
# Limit length
-
base_name = base_name[0..30] if base_name.length > 30
-
-
# Ensure it starts with a letter
-
base_name = "str_#{base_name}" unless base_name.match?(/^[a-z]/)
-
-
# Handle empty or invalid names
-
base_name = "str_text" if base_name.empty?
-
-
# Make unique if needed
-
final_name = base_name
-
counter = 2
-
-
while @strings_cache.key?(final_name) || @new_strings.key?(final_name)
-
final_name = "#{base_name}_#{counter}"
-
counter += 1
-
end
-
-
final_name
-
end
-
-
1
def escape_xml_text(text)
-
# Escape special characters for XML
-
text.gsub('&', '&')
-
.gsub('<', '<')
-
.gsub('>', '>')
-
.gsub('"', '"')
-
.gsub("'", ''')
-
end
-
-
1
def unescape_xml_text(text)
-
# Unescape XML entities
-
1
text.gsub('&', '&')
-
.gsub('<', '<')
-
.gsub('>', '>')
-
.gsub('"', '"')
-
.gsub(''', "'")
-
end
-
end
-
end
-
end
-
#!/usr/bin/env ruby
-
-
1
require 'json'
-
1
require 'fileutils'
-
1
require_relative '../core/config_manager'
-
1
require_relative '../core/project_finder'
-
1
require_relative '../core/attribute_validator'
-
1
require_relative '../core/binding_validator'
-
1
require_relative 'xml_generator'
-
-
1
module KjuiTools
-
1
module Xml
-
1
class XmlBuilder
-
1
attr_accessor :validation_enabled, :validation_callback
-
-
1
def initialize(config = nil)
-
11
@config = config || Core::ConfigManager.load_config
-
11
Core::ProjectFinder.setup_paths
-
# Use current directory as project path (where kjui.config.json is located)
-
11
@project_path = Dir.pwd
-
11
@layouts_dir = File.join(@project_path, @config['source_directory'] || 'src/main', @config['layouts_directory'] || 'assets/Layouts')
-
11
@output_dir = File.join(@project_path, @config['source_directory'] || 'src/main', 'res/layout')
-
11
@generated_count = 0
-
11
@failed_count = 0
-
11
@skipped_count = 0
-
11
@validation_enabled = false
-
11
@validation_callback = nil
-
11
@validator = nil
-
11
@binding_validator = nil
-
end
-
-
1
def build(options = {})
-
7
puts "🔨 Building XML View files..."
-
7
puts "📁 Project: #{@project_path}"
-
7
puts "📂 Layouts: #{@layouts_dir}"
-
7
puts "📂 Output: #{@output_dir}"
-
7
puts "-" * 60
-
-
7
unless Dir.exist?(@layouts_dir)
-
1
puts "❌ Layouts directory not found: #{@layouts_dir}"
-
1
return false
-
end
-
-
# Clean output directory if requested
-
6
if options[:clean]
-
1
clean_output_directory
-
end
-
-
# Ensure output directory exists
-
6
FileUtils.mkdir_p(@output_dir)
-
-
# Initialize validators if validation is enabled
-
6
@validator = Core::AttributeValidator.new(:xml) if @validation_enabled
-
6
@binding_validator = Core::BindingValidator.new if @validation_enabled
-
-
# Get all JSON files (excluding Resources folder)
-
6
json_files = Dir.glob(File.join(@layouts_dir, '*.json'))
-
# Also get JSON files from subdirectories, but exclude Resources
-
6
json_files += Dir.glob(File.join(@layouts_dir, '**/*.json')).reject do |file|
-
4
file.include?('/Resources/')
-
end
-
6
json_files.uniq!
-
-
6
if json_files.empty?
-
2
puts "⚠️ No JSON files found in #{@layouts_dir}"
-
2
return true
-
end
-
-
4
puts "📄 Found #{json_files.length} JSON files"
-
4
puts "-" * 60
-
-
# Extract resources before processing layouts
-
4
require_relative '../core/resources_manager'
-
4
resources_manager = Core::ResourcesManager.new(@config, @project_path)
-
4
resources_manager.extract_resources(json_files)
-
4
puts "-" * 60
-
-
# Process each file
-
4
json_files.each do |json_file|
-
4
process_layout(json_file, options)
-
end
-
-
# Print summary
-
4
puts "-" * 60
-
4
puts "✅ Build Complete!"
-
4
puts " Generated: #{@generated_count} files"
-
4
puts " Failed: #{@failed_count} files" if @failed_count > 0
-
4
puts " Skipped: #{@skipped_count} files" if @skipped_count > 0
-
-
4
@failed_count == 0
-
end
-
-
1
private
-
-
1
def clean_output_directory
-
1
puts "🧹 Cleaning output directory..."
-
-
1
if Dir.exist?(@output_dir)
-
# Only remove generated XML files (those with our comment marker)
-
1
Dir.glob(File.join(@output_dir, '*.xml')).each do |file|
-
1
content = File.read(file)
-
1
if content.include?('<!-- Generated from') && content.include?('.json')
-
1
puts " Removing: #{File.basename(file)}"
-
1
File.delete(file)
-
end
-
end
-
end
-
end
-
-
# Validate a JSON component and all its children recursively
-
1
def validate_json(json_data)
-
1
return [] unless json_data.is_a?(Hash)
-
-
1
warnings = @validator.validate(json_data)
-
-
# Validate children recursively
-
1
children = json_data['child'] || json_data['children'] || []
-
1
children = [children] unless children.is_a?(Array)
-
-
1
children.each do |child|
-
warnings.concat(validate_json(child)) if child.is_a?(Hash)
-
end
-
-
# Validate sections (for Collection/Table)
-
1
if json_data['sections'].is_a?(Array)
-
json_data['sections'].each do |section|
-
if section.is_a?(Hash)
-
['header', 'footer', 'cell'].each do |key|
-
warnings.concat(validate_json(section[key])) if section[key].is_a?(Hash)
-
end
-
end
-
end
-
end
-
-
1
warnings
-
end
-
-
1
def process_layout(json_file, options = {})
-
4
layout_name = File.basename(json_file, '.json')
-
-
# Skip partial/included files (convention: starts with underscore)
-
4
if layout_name.start_with?('_')
-
1
puts " ⏭️ Skipping partial: #{layout_name}"
-
1
@skipped_count += 1
-
1
return
-
end
-
-
# Skip cell templates (they're used in collections)
-
3
if layout_name.end_with?('_cell') || layout_name.include?('cell')
-
1
puts " ⏭️ Skipping cell template: #{layout_name}"
-
1
@skipped_count += 1
-
1
return
-
end
-
-
# Skip included files (used by include mechanism)
-
2
if layout_name.start_with?('included')
-
puts " ⏭️ Skipping include file: #{layout_name}"
-
@skipped_count += 1
-
return
-
end
-
-
2
print " 📝 Processing: #{layout_name}..."
-
-
begin
-
# Validate JSON if enabled
-
2
if @validation_enabled && @validator
-
1
json_content = File.read(json_file)
-
1
json_data = JSON.parse(json_content)
-
1
warnings = validate_json(json_data)
-
-
1
if warnings.any?
-
1
puts " ⚠️ #{warnings.length} attribute warning(s)"
-
1
@validation_callback&.call(layout_name, warnings)
-
end
-
-
# Validate bindings for business logic
-
1
if @binding_validator
-
1
binding_warnings = @binding_validator.validate(json_data, layout_name)
-
1
if binding_warnings.any?
-
puts " ⚠️ #{binding_warnings.length} binding warning(s)"
-
@validation_callback&.call(layout_name, binding_warnings)
-
end
-
end
-
end
-
-
# Ensure project_path is set in config
-
2
config_with_path = @config.merge('project_path' => @project_path)
-
-
# Generate XML using the existing generator
-
2
generator = XmlGenerator::Generator.new(layout_name, config_with_path)
-
2
if generator.generate
-
2
@generated_count += 1
-
2
puts " ✅" unless @validation_enabled && warnings&.any?
-
else
-
@failed_count += 1
-
puts " ❌"
-
end
-
rescue JSON::ParserError => e
-
@failed_count += 1
-
puts " ❌"
-
puts " JSON Parse Error: #{e.message}"
-
rescue => e
-
@failed_count += 1
-
puts " ❌"
-
puts " Error: #{e.message}"
-
puts e.backtrace.first(5).map { |line| " #{line}" }.join("\n")
-
end
-
end
-
end
-
end
-
end
-
-
# Allow running directly
-
1
if __FILE__ == $0
-
require_relative '../core/config_manager'
-
-
config = KjuiTools::Core::ConfigManager.load_config
-
builder = KjuiTools::Xml::XmlBuilder.new(config)
-
-
options = {}
-
ARGV.each do |arg|
-
case arg
-
when '--clean', '-c'
-
options[:clean] = true
-
when '--debug', '-d'
-
config['debug'] = true
-
when '--validate', '-v'
-
builder.validation_enabled = true
-
end
-
end
-
-
builder.build(options)
-
end
-
#!/usr/bin/env ruby
-
-
1
require 'json'
-
1
require 'fileutils'
-
1
require 'set'
-
1
require 'nokogiri'
-
1
require_relative '../core/json_loader'
-
1
require_relative '../core/style_loader'
-
1
require_relative 'helpers/component_mapper'
-
1
require_relative 'helpers/attribute_mapper'
-
1
require_relative 'helpers/binding_parser'
-
1
require_relative 'helpers/layout_attribute_processor'
-
1
require_relative 'helpers/data_binding_helper'
-
1
require_relative 'drawable/drawable_generator'
-
1
require_relative 'resources/string_resource_manager'
-
-
1
module XmlGenerator
-
1
class Generator
-
1
def initialize(layout_name, config, options = {})
-
24
@layout_name = layout_name
-
24
@config = config
-
24
@options = options
-
24
@json_loader = JsonLoader.new(config)
-
24
@style_loader = StyleLoader.new(config)
-
24
@component_mapper = ComponentMapper.new
-
-
# Initialize resource managers
-
24
project_root = @config['project_path']
-
24
@drawable_generator = DrawableGenerator::Generator.new(project_root)
-
24
@string_resource_manager = Resources::StringResourceManager.new(project_root)
-
-
24
@attribute_mapper = AttributeMapper.new(@drawable_generator, @string_resource_manager)
-
24
@binding_parser = BindingParser.new
-
24
@layout_processor = LayoutAttributeProcessor.new(@attribute_mapper)
-
-
# Get package name from config or auto-detect
-
24
@package_name = @config['package_name'] || detect_package_name
-
-
# Allow custom output filename
-
24
@output_filename = options[:output_filename]
-
end
-
-
1
def generate
-
4
puts "Generating XML for #{@layout_name}..."
-
-
# Load JSON
-
4
json_content = @json_loader.load_layout(@layout_name)
-
4
if json_content.nil?
-
1
puts "Error: Could not load layout #{@layout_name}"
-
1
return false
-
end
-
-
# Parse JSON
-
3
layout_data = JSON.parse(json_content)
-
-
# Apply styles
-
3
layout_data = @style_loader.apply_styles(layout_data)
-
-
# Generate XML
-
3
xml_content = generate_xml(layout_data)
-
-
# Save XML file
-
3
save_xml(xml_content)
-
-
# Save any new strings to strings.xml
-
3
@string_resource_manager.save_new_strings
-
-
3
true
-
rescue => e
-
puts "Error generating XML: #{e.message}"
-
puts " Backtrace:"
-
e.backtrace[0..4].each { |line| puts " #{line}" }
-
false
-
end
-
-
1
private
-
-
1
def detect_package_name
-
# Try to detect from AndroidManifest.xml
-
manifest_paths = [
-
File.join(@config['project_path'], 'src', 'main', 'AndroidManifest.xml'),
-
File.join(@config['project_path'], 'app', 'src', 'main', 'AndroidManifest.xml')
-
]
-
-
manifest_paths.each do |path|
-
if File.exist?(path)
-
content = File.read(path)
-
if content =~ /package="([^"]+)"/
-
return $1
-
end
-
end
-
end
-
-
# Try to detect from build.gradle
-
gradle_paths = [
-
File.join(@config['project_path'], 'build.gradle'),
-
File.join(@config['project_path'], 'app', 'build.gradle'),
-
File.join(@config['project_path'], 'build.gradle.kts'),
-
File.join(@config['project_path'], 'app', 'build.gradle.kts')
-
]
-
-
gradle_paths.each do |path|
-
if File.exist?(path)
-
content = File.read(path)
-
# Look for namespace
-
if content =~ /namespace\s*[=:]\s*["']([^"']+)["']/
-
return $1
-
end
-
# Look for applicationId
-
if content =~ /applicationId\s*[=:]\s*["']([^"']+)["']/
-
return $1
-
end
-
end
-
end
-
-
# Default
-
'com.example.app'
-
end
-
-
1
def generate_xml(json_data)
-
# Check if layout uses data binding
-
3
has_binding = check_for_bindings(json_data)
-
-
3
if has_binding
-
generate_data_binding_xml(json_data)
-
else
-
3
generate_regular_xml(json_data)
-
end
-
end
-
-
1
def check_for_bindings(json_data)
-
# Recursively check for @{} syntax in the JSON
-
5
json_string = json_data.to_json
-
5
json_string.include?('@{')
-
end
-
-
1
def generate_data_binding_xml(json_data)
-
# Extract all binding variables
-
variables = extract_binding_variables(json_data)
-
-
builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
-
xml.comment " Generated from #{@layout_name}.json with Data Binding "
-
xml.comment " DO NOT EDIT MANUALLY - Use 'kjui generate' to update "
-
-
# Create layout root for data binding
-
xml.layout('xmlns:android' => 'http://schemas.android.com/apk/res/android',
-
'xmlns:app' => 'http://schemas.android.com/apk/res-auto',
-
'xmlns:tools' => 'http://schemas.android.com/tools') do
-
-
# Add data section
-
xml.data do
-
# Add common imports
-
xml.import(type: 'android.view.View')
-
-
# Add data variable
-
if has_data_definitions?(json_data)
-
data_class = "#{camelize(@layout_name)}Data"
-
xml.variable(name: 'data', type: "#{@package_name}.data.#{data_class}")
-
end
-
-
# Add viewModel variable if there are onClick handlers
-
if has_click_handlers?(json_data)
-
view_model_class = "#{camelize(@layout_name)}ViewModel"
-
xml.variable(name: 'viewModel', type: "#{@package_name}.viewmodels.#{view_model_class}")
-
end
-
end
-
-
# Add the actual layout content
-
# Pass false for is_root since namespaces are already on <layout> tag
-
create_xml_element(xml, json_data, false)
-
end
-
end
-
-
# Format the XML nicely
-
doc = Nokogiri::XML(builder.to_xml) do |config|
-
config.default_xml.noblanks
-
end
-
-
# Pretty print with proper indentation
-
formatted_xml = doc.to_xml(
-
indent: 4,
-
indent_text: ' ',
-
save_with: Nokogiri::XML::Node::SaveOptions::FORMAT |
-
Nokogiri::XML::Node::SaveOptions::AS_XML
-
)
-
-
# Additional formatting: put each attribute on a new line for better readability
-
format_attributes(formatted_xml)
-
end
-
-
1
def generate_regular_xml(json_data)
-
3
builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
-
3
xml.comment " Generated from #{@layout_name}.json "
-
3
xml.comment " DO NOT EDIT MANUALLY - Use 'kjui generate' to update "
-
-
# Create root layout
-
3
create_xml_element(xml, json_data, true)
-
end
-
-
# Format the XML nicely
-
3
doc = Nokogiri::XML(builder.to_xml) do |config|
-
3
config.default_xml.noblanks
-
end
-
-
# Pretty print with proper indentation
-
3
formatted_xml = doc.to_xml(
-
indent: 4,
-
indent_text: ' ',
-
save_with: Nokogiri::XML::Node::SaveOptions::FORMAT |
-
Nokogiri::XML::Node::SaveOptions::AS_XML
-
)
-
-
# Additional formatting: put each attribute on a new line for better readability
-
3
format_attributes(formatted_xml)
-
end
-
-
1
def extract_binding_variables(json_data)
-
2
variables = Set.new
-
2
extract_variables_recursive(json_data, variables)
-
2
variables
-
end
-
-
1
def extract_variables_recursive(data, variables)
-
5
if data.is_a?(Hash)
-
4
data.each do |key, value|
-
5
if value.is_a?(String) && value.start_with?('@{')
-
# Extract variable name from binding expression
-
3
if value.match(/@\{([^}]+)\}/)
-
3
expr = $1
-
# Simple variable extraction (can be enhanced)
-
3
if expr.match(/^(\w+)/)
-
3
variables.add($1)
-
end
-
end
-
2
elsif value.is_a?(Hash) || value.is_a?(Array)
-
1
extract_variables_recursive(value, variables)
-
end
-
end
-
1
elsif data.is_a?(Array)
-
3
data.each { |item| extract_variables_recursive(item, variables) }
-
end
-
end
-
-
1
def has_click_handlers?(json_data)
-
3
json_string = json_data.to_json
-
3
json_string.include?('"onClick"') || json_string.include?('"onclick"')
-
end
-
-
1
def camelize(snake_case)
-
2
snake_case.split('_').map(&:capitalize).join
-
end
-
-
1
def needs_tools_namespace?(json_element)
-
# Check if this element or any of its children use tools attributes
-
6
json_string = json_element.to_json
-
6
json_string.include?('"tools:') || json_string.include?('"title"') || json_string.include?('"count"')
-
end
-
-
1
def has_data_definitions?(json_data)
-
# Check if there are any data definitions anywhere in the JSON structure
-
3
return true if json_data['data']
-
-
# Check children recursively
-
2
if json_data['child']
-
1
children = json_data['child'].is_a?(Array) ? json_data['child'] : [json_data['child']]
-
1
children.each do |child|
-
1
return true if child.is_a?(Hash) && child['data']
-
return true if child.is_a?(Hash) && has_data_definitions?(child)
-
end
-
end
-
-
1
if json_data['children']
-
children = json_data['children'].is_a?(Array) ? json_data['children'] : [json_data['children']]
-
children.each do |child|
-
return true if child.is_a?(Hash) && child['data']
-
return true if child.is_a?(Hash) && has_data_definitions?(child)
-
end
-
end
-
-
1
false
-
end
-
-
1
def create_xml_element(xml, json_element, is_root = false, parent_orientation = nil, parent_type = nil)
-
# Map JSON type to Android view class (pass json_element for View type checking)
-
5
view_class = @component_mapper.map_component(json_element['type'], json_element)
-
-
# Prepare all attributes first
-
5
attrs = {}
-
-
# Add namespace declarations if this is the root element
-
5
if is_root
-
3
attrs['xmlns:android'] = 'http://schemas.android.com/apk/res/android'
-
# Always add app namespace as it's commonly needed for ConstraintLayout and custom attributes
-
3
attrs['xmlns:app'] = 'http://schemas.android.com/apk/res-auto'
-
# Add tools namespace if we're using tools attributes
-
3
if needs_tools_namespace?(json_element)
-
attrs['xmlns:tools'] = 'http://schemas.android.com/tools'
-
end
-
end
-
-
# Add ID if present
-
5
if json_element['id']
-
attrs['android:id'] = "@+id/#{json_element['id']}"
-
end
-
-
# Process layout dimensions
-
5
dimension_attrs = @layout_processor.process_dimensions(json_element, is_root, parent_orientation)
-
5
attrs.merge!(dimension_attrs)
-
-
# Process orientation for LinearLayout
-
5
orientation_attrs = @layout_processor.process_orientation(view_class, json_element)
-
5
attrs.merge!(orientation_attrs)
-
-
# Process all other attributes
-
5
other_attrs = @layout_processor.process_attributes(json_element, parent_type)
-
5
attrs.merge!(other_attrs)
-
-
# Determine orientation for children
-
5
current_orientation = nil
-
5
if view_class == 'LinearLayout'
-
current_orientation = json_element['orientation'] || 'vertical'
-
end
-
-
# Create element with attributes
-
# For custom views with package name, use the full class name
-
5
if view_class.include?('.')
-
# Custom view with package name - create element directly
-
5
xml.send(:method_missing, view_class, attrs) do
-
5
create_children(xml, json_element, current_orientation, view_class)
-
end
-
else
-
# Standard Android view
-
xml.send(view_class, attrs) do
-
create_children(xml, json_element, current_orientation, view_class)
-
end
-
end
-
end
-
-
-
1
def create_children(parent_element, json_element, parent_orientation = nil, parent_type = nil)
-
# Handle children
-
5
children = json_element['children'] || json_element['child']
-
5
return unless children
-
-
3
children = [children] unless children.is_a?(Array)
-
-
3
children.each do |child|
-
# Skip data definitions - they don't create UI elements
-
2
next if child.is_a?(Hash) && child.key?('data') && !child.key?('type')
-
-
2
create_xml_element(parent_element, child, false, parent_orientation, parent_type)
-
end
-
end
-
-
-
1
def format_attributes(xml_string)
-
# Format XML to put each attribute on its own line for better readability
-
6
lines = xml_string.split("\n")
-
6
formatted_lines = []
-
-
6
lines.each do |line|
-
# Skip comments and empty lines
-
19
if line.strip.start_with?('<!--') || line.strip.start_with?('<?xml') || line.strip.empty?
-
11
formatted_lines << line
-
11
next
-
end
-
-
# Check if line contains an XML tag with attributes
-
8
if line =~ /^(\s*)<([^\/\s>]+)(.*?)(\s*\/?>.*?)$/
-
6
indent = $1
-
6
tag_name = $2
-
6
attributes_str = $3
-
6
tag_end = $4
-
-
# Parse all attributes including namespace prefixes
-
6
attributes = []
-
6
attributes_str.scan(/(\S+?)="([^"]*)"/) do |attr_name, attr_value|
-
23
attributes << [attr_name, attr_value]
-
end
-
-
# Format based on number of attributes
-
6
if attributes.size > 1
-
# Multiple attributes - put each on its own line
-
5
formatted_lines << "#{indent}<#{tag_name}"
-
5
attributes.each do |attr_name, attr_value|
-
22
formatted_lines << "#{indent} #{attr_name}=\"#{attr_value}\""
-
end
-
# Handle closing tag
-
5
if tag_end.strip == '/>'
-
3
formatted_lines[-1] += '/>'
-
2
elsif tag_end.include?('>')
-
# Check if there's content after the >
-
2
if tag_end =~ />\s*(.+)$/
-
content = $1
-
formatted_lines[-1] += '>'
-
# Add the content on the same line if it's simple text
-
if content && !content.empty?
-
formatted_lines[-1] += content
-
end
-
else
-
2
formatted_lines[-1] += '>'
-
end
-
else
-
formatted_lines[-1] += tag_end.strip
-
end
-
1
elsif attributes.size == 1
-
# Single attribute - can stay on one line
-
1
formatted_lines << line
-
else
-
# No attributes
-
formatted_lines << line
-
end
-
else
-
# Not a tag line or closing tag
-
2
formatted_lines << line
-
end
-
end
-
-
6
formatted_lines.join("\n")
-
end
-
-
1
def save_xml(xml_content)
-
# Determine output path
-
3
output_dir = File.join(@config['project_path'], 'src', 'main', 'res', 'layout')
-
3
output_dir = File.join(@config['project_path'], 'app', 'src', 'main', 'res', 'layout') if File.exist?(File.join(@config['project_path'], 'app'))
-
3
FileUtils.mkdir_p(output_dir)
-
-
# Use custom filename if provided, otherwise use default
-
3
filename = @output_filename || "#{@layout_name.downcase}.xml"
-
3
output_file = File.join(output_dir, filename)
-
-
# Save XML file
-
3
File.write(output_file, xml_content)
-
3
puts "✅ Generated: #{output_file}"
-
end
-
end
-
end